1
0
Fork 0
mirror of synced 2024-05-20 04:22:58 +12:00

Compare commits

...

147 commits

Author SHA1 Message Date
Stelios Tsampas 94c2f765f3
Merge pull request #407 from loathingKernel/develop
RareLauncher: Fix console window wrapping and skip DLC check for launchable addons
2024-05-19 15:29:36 +03:00
loathingKernel 71348dbf23 RareLauncher: Refactor signal name 2024-05-19 15:19:38 +03:00
loathingKernel 2396f0fb83 RareLauncher: Allow running launchable addons 2024-05-19 15:15:13 +03:00
loathingKernel 6dd5d2f546 GameDetails: Fix form growth policy 2024-05-19 14:52:34 +03:00
loathingKernel a4db64082f RareLauncher: Fix word wrapping in console window 2024-05-19 13:11:56 +03:00
Stelios Tsampas 50ff2ae4fd
Merge pull request #405 from loathingKernel/develop
Support paths with spaces in pre launch command field
2024-05-18 18:22:20 +03:00
loathingKernel 0ac4cf5a7c LaunchSettingsBase: Use shlex.split validate the pre-launch command 2024-05-18 18:16:51 +03:00
loathingKernel 259bc98eb9 PreLaunch: Quote the selected file path if it contains spaces. 2024-05-18 18:15:25 +03:00
loathingKernel 6594d65be1 RareGame: Improve launch message 2024-05-17 23:50:02 +03:00
Stelios Tsampas 42341d5c81
Merge pull request #404 from RareDevs/loathingKernel-patch-1
Create codeql.yml
2024-05-17 23:42:43 +03:00
Stelios Tsampas 4dea963154
Merge pull request #403 from loathingKernel/develop
RareLauncher: Fix wrong arguments in pre-launch command `QProcess.start()`
2024-05-17 23:40:24 +03:00
Stelios Tsampas 87b0ae0540
Create codeql.yml 2024-05-17 23:36:22 +03:00
loathingKernel 9b476afe8c RareLauncher: Fix wrong arguments in pre-launch command QProcess.start() 2024-05-17 23:29:35 +03:00
loathingKernel 95e760791b DetailsWidget: Rename to StoreDetailsWidget
In continuation of the previous change, prepend `Store` to the name to
specify the difference.

The goal is to add `StoreDetailsWidget` as a second view in `GameDetails`
with information sourced from the Epic Games Store.
2024-05-17 13:34:24 +03:00
loathingKernel fec4e3c0e1 GameDetails: Refactor file structure
`GameInfo` has been renamed to `GameDetails` to align it with
the similar `StoreDetails` page while making the difference clearer.

Remove the `game_` prefix from the file names to reduce noise. The path
should be enough to provide scope.
2024-05-17 13:31:48 +03:00
Stelios Tsampas fb736fa47b
Merge pull request #401 from loathingKernel/develop
Address pylint errors
2024-05-16 19:32:44 +03:00
loathingKernel 3b721fdd13 WinePathResolver: Disable pylint check (possibly-used-before-assignment) 2024-05-16 19:29:14 +03:00
loathingKernel f9cc1b48f1 GameSettings: Fix pylint errors (possibly-used-before-assignment) 2024-05-16 19:27:59 +03:00
loathingKernel 9017826b16 StoreAPI: Log exception 2024-05-16 13:58:59 +03:00
loathingKernel 4bb1fb10ee SteamGrades: Print an error message instead of the exception 2024-05-16 13:58:10 +03:00
loathingKernel beb4f6c310 Workflows: Update Windows packages to python 3.12
Signed-off-by: loathingKernel <142770+loathingKernel@users.noreply.github.com>
2024-05-16 13:57:30 +03:00
loathingKernel 337d753d0c Workflows: Disable fail-fast for pylint checks 2024-05-16 13:38:16 +03:00
loathingKernel 4497a1c712 Project: Use vdf package on all platforms
Useful on Windows too, to export games to Steam as "Non-Steam Game"
2024-05-16 13:32:00 +03:00
loathingKernel 07fa7890cf RareGame: Update protondb grade every 3 days even if it already exists. 2024-05-16 13:18:05 +03:00
loathingKernel 1d8ae15ea9 SteamGrades: Fix exception when trying to get file timestamp
if the file does not exist.

At the same time fix some pylint warnings.
2024-05-16 13:15:48 +03:00
loathingKernel b9d034eef7 Project: Add pylint configuration file 2024-05-16 11:42:32 +03:00
Stelios Tsampas 9f67f930a9
Merge pull request #400 from loathingKernel/develop
LoadingWidget: Start playing movie once the widget is visible if `autostart` is enabled
2024-05-15 22:50:02 +03:00
loathingKernel 8ebcc3a700 LoadingWidget: Start playing movie once the widget is visible if autostart is enabled
Fixes a subtle bug that would cause increased CPU usage due to spawning
multiple singleshot times with very short timeouts (15ms) until the Store
tab was loaded.
2024-05-15 22:48:52 +03:00
Stelios Tsampas 0ddd5a0674
Merge pull request #399 from loathingKernel/develop
WineResolver: Do not unset 'DISPLAY' when running silently
2024-05-15 15:18:14 +03:00
loathingKernel 0c848c41b0 WineResolver: Do not unset 'DISPLAY' when running silently
When unsetting DISPLAY, Wine hangs after executing a command, and doesn't
allow for other instances of wine to start, i.e. games do not launch, with
a cryptic error. This also fixes the previously observed issues with
`winepath.exe` and `reg.exe` never exiting.

This is likely due to the newly introduced Wayland driver. This behavior
can be observed when Wayland support is compiled into Wine and the wayland
driver is enabled in the prefix's registry. In this case, if running under
X11 and DISPLAY is not set, Wine will hang, and the process never returns.
2024-05-15 15:13:51 +03:00
Stelios Tsampas 8f018cb162
Merge pull request #396 from ARez2/main
Fix typo in README
2024-04-13 06:40:38 +03:00
ARez 34abca2a4e
Fix typo in README 2024-04-13 01:34:09 +02:00
Stelios Tsampas cb919ed69f
Merge pull request #392 from loathingKernel/next
commands: add `__init__.py` for module discovery
2024-03-06 17:59:27 +02:00
loathingKernel 1c886535a5
commands: add __init__.py for module discovery 2024-03-06 17:57:48 +02:00
Stelios Tsampas a35e430539
Merge pull request #388 from loathingKernel/shop_refactor
Refactor the Store page
2024-02-26 12:47:24 +02:00
loathingKernel 14bde0a23c
WishlistWidget: re-order comboboxes to match the order in the library
* Move filter combobox at the beginning of the top panel and move the order
combobox after it to replicate the order in the library view.
2024-02-25 21:35:45 +02:00
loathingKernel 89340f331b
StoreAPI: comment unused and erroneous code 2024-02-25 21:35:45 +02:00
loathingKernel fb0d5bbe10
Store: Fix various wishlist issues.
* Use horizontal scrollarea for free games. Based on the same idea as
WrapperSettings scrollarea. Both need some adjustments.

* Remove debugging dialogs. Need a better way anyways to debug.
2024-02-25 21:35:45 +02:00
loathingKernel 766557924a
Store: Unset autoFillBackground for some scrollareas 2024-02-25 21:35:45 +02:00
loathingKernel 1fab13fd92
Store: Fix rebase errors 2024-02-25 21:35:44 +02:00
loathingKernel 7a2a6458ed
Store: Update details page 2024-02-25 21:35:44 +02:00
loathingKernel 9ec349e2d1
WIP 2024-02-25 21:35:44 +02:00
loathingKernel 2a2458bacb
Store: Update details page
* Add a big back button in details page.

* Add static CSS to render QPushButtons as flat when the `flat` property is set

* Remove outer scroll areas from details page since the page is already adjustable

* Remove scroll area from the requirements widget because ElideLabels are already used in it.

* Fix crash when sorting the wishlist
2024-02-25 21:35:44 +02:00
loathingKernel 816c5f3de9
Store: adapt image sized 2024-02-25 21:35:44 +02:00
loathingKernel b4a26b5932
Store: Finalize store design 2024-02-25 21:35:43 +02:00
loathingKernel fda82b17cf
Shop: Use a single QGridLayout instead of left and right VBoxLayouts 2024-02-25 21:35:43 +02:00
loathingKernel 91af16b76d
Store: Exploratory changes for GraphQL API 2024-02-25 21:35:43 +02:00
loathingKernel f6396f488a
Store: Exploratory changes to the store page
Important changes:
* Refactored QtRequests to accept parameters for `GET` operations
* Infer response data type from content-type header
* Support caching to disk, a manager with this set prefers the cache
* Support multiple handlers for a single request (unused, possibly pointeless)

* Subclass `ShopImageWidget` for all widgets used in the shop
* Request a resized image instead of the original one
* Fix the search and browse functions
2024-02-25 21:35:43 +02:00
loathingKernel 7246078df3
ShopGameInfo: Design it to me similar to GameInfo 2024-02-25 21:35:43 +02:00
loathingKernel 247b2c947a
ShopImageWidget: Design it to me similar to IconGameWidget 2024-02-25 21:35:43 +02:00
loathingKernel b6458b1bfc
SearchResultItem: Use ShopImageWidget as a base 2024-02-25 21:35:42 +02:00
loathingKernel d76fc2b68b
ShopWidget: Fix layouting again 2024-02-25 21:35:42 +02:00
loathingKernel 2db34324af
ShopWidget: Cleanup shop layout 2024-02-25 21:35:42 +02:00
loathingKernel b812e38fb8
Wishlist: Remove embedded title 2024-02-25 21:35:42 +02:00
loathingKernel d3b591952f
Store: Use ElideLabel for requirements 2024-02-25 21:35:42 +02:00
loathingKernel 6b15c0f2cf
Store: Clean up store UI by using library widgets 2024-02-25 21:35:41 +02:00
Stelios Tsampas 784fadb2da
Merge pull request #389 from loathingKernel/next
Fix action button positions in QueueBaseWidgets
2024-02-25 15:49:57 +02:00
loathingKernel c768b6ac3b
Repo: fix malformed gitattributes 2024-02-25 15:46:25 +02:00
loathingKernel c1b92c3ae5
QueueBaseWidget: fix button positioning 2024-02-25 15:36:00 +02:00
loathingKernel 1c578e354e
HeadBar: Fix commented code 2024-02-25 15:35:34 +02:00
loathingKernel f94ab70287
Repo: set rare/resources/static_css/__init__.py as binary 2024-02-24 15:29:29 +02:00
Stelios Tsampas 5b6df91be9
Merge pull request #387 from loathingKernel/next
More styling updates
2024-02-24 14:03:52 +02:00
loathingKernel 7b810173da
Downloads: unset autoFillBackground for queue scrollarea 2024-02-24 13:56:15 +02:00
loathingKernel f3d870cebb
ButtonLineEdit: Remove stylesheet, it doesn't seem to do anything 2024-02-24 13:12:00 +02:00
loathingKernel e50015c25c
WrapperSettings: Improve widget structure
* Remove the annoying stacked widget, instead create a double layout in
the container and place a label in that to hold the placeholder message.

* Implement the scroll area as a custom widget. This custom widget installs
an event filter on the container to adjust the scrollarea size when the
container size changes.
2024-02-24 13:11:28 +02:00
loathingKernel 6cfec6c718
Style: Update Rare colorscheme and remove some styling from WrapperSettings widget 2024-02-24 02:41:05 +02:00
loathingKernel 570261395a
SideTabContainer: Do not darken background inside scrollarea 2024-02-24 02:27:14 +02:00
loathingKernel 2e8dcc49ca
Settings: Translate titles 2024-02-24 02:25:47 +02:00
loathingKernel 379cbd2f89
Painter: Use QPalette.Window as background color 2024-02-24 02:22:25 +02:00
loathingKernel ae69413ddb
InstallDialog: Remove inline stylesheet, adjust the font. 2024-02-24 01:51:45 +02:00
loathingKernel 8a421b08f8
Stylesheets: set minimum height in ex 2024-02-23 10:47:31 +02:00
loathingKernel e8e39fa391
Stylesheets: Remove padding from progressbar 2024-02-23 00:02:15 +02:00
Stelios Tsampas 7dff47c5ac
Merge pull request #386 from loathingKernel/next
Steam: Fix crash if Steam is not installed
2024-02-22 21:23:32 +02:00
loathingKernel 49c06aef79
RareStyle: Use px instead of em 2024-02-22 21:22:28 +02:00
loathingKernel 1c027fc14a
Steam: Fix crash if Steam is not installed 2024-02-22 21:19:05 +02:00
loathingKernel 980bac5c4e
LaunchSettings: Fix browsing the wrong directory if override exe is set
The dialog would default to CWD because the contents of the line edit
where not an absolute path.
2024-02-22 17:26:38 +02:00
loathingKernel d397912247
StyleSheets: Fix padding in QLineEdits used is Table/Tree/ListViews 2024-02-22 17:12:51 +02:00
Stelios Tsampas 4548a8b1e0
Merge pull request #385 from loathingKernel/next
Various UI fixes and CSS updates
2024-02-22 12:08:53 +02:00
loathingKernel 243b92248e
MainTabWidget: Remove icon from account tab 2024-02-21 20:55:10 +02:00
loathingKernel 715ac06719
Launcher: Move UI files under commands to mirror project structure 2024-02-21 20:39:10 +02:00
loathingKernel 5bf353ec37
Rare: Replace QToolButton with QPushButton
QToolButton is not really designed to be used in the way we did and since
QPushButton supports having a menu attached to, we can replace tool buttons
in most cases.

* Fix the presentation of the TabButtonWidget by updating RareStyle's css

* Reduce the size of the top tab bar to save vertical space.

* Remove infoLabel property
2024-02-21 20:25:03 +02:00
loathingKernel 8dbce8e9f2
Rare: rename icon function to qta_icon 2024-02-21 13:30:41 +02:00
loathingKernel f542e11b25
HeadBar: better alignment to center 2024-02-20 13:41:47 +02:00
Stelios Tsampas 04868fcf25
Merge pull request #383 from RareDevs/fixes
Fixes
2024-02-20 13:38:13 +02:00
loathingKernel ddbd94354c
UI: Update LegendarySettings UI file 2024-02-20 13:36:06 +02:00
lennard 91d8cb336d
Update languages 2024-02-19 23:54:58 +01:00
lennard f2c63aa3b4
Set desktop file name to fix default icon on wayland 2024-02-19 22:44:12 +01:00
Stelios Tsampas 557189f41b
Merge pull request #381 from loathingKernel/next
Merge a lot of WIP
2024-02-18 13:00:05 +02:00
loathingKernel 5b217e0b15
Overlay: remove print statements 2024-02-18 12:54:24 +02:00
loathingKernel d16b3d5d68
Runners: rename proton to steam 2024-02-18 12:50:35 +02:00
loathingKernel 68ea7b9ca1
Merge branch 'develop' of github.com:loathingKernel/Rare into develop 2024-02-17 19:00:33 +02:00
Stelios Tsampas db3cf68d19 LaunchSettings: Work around path separator difference on Windows 2024-02-17 18:38:29 +02:00
loathingKernel 5359b73c35
Settings: Use membership instead of double inheritance 2024-02-16 13:03:27 +02:00
loathingKernel 6db35d1f1e
RareApp: Call quit on instance 2024-02-16 12:28:00 +02:00
loathingKernel e776ed457a
WineSettings: Order executable before prefix 2024-02-16 12:27:06 +02:00
loathingKernel 582b83c12b WIP 2024-02-12 21:52:08 +02:00
loathingKernel 3fe02e5026 GameProcess: Reset tried connections after accepting the dialog.
Signed-off-by: loathingKernel <142770+loathingKernel@users.noreply.github.com>
2024-02-12 21:52:08 +02:00
loathingKernel 6a747ce0f7 Rare: Move sub-commands implementations in the commands subfolder
Right now we have two commands, and with possible third one comming soon.
2024-02-12 21:52:08 +02:00
loathingKernel 2f84a501d5 Launcher: Color output to STDERR as a slight red 2024-02-12 21:52:08 +02:00
loathingKernel b7b1bc6406 RareSettings: Use QDesktopServices to open the file manager for us 2024-02-12 21:52:08 +02:00
loathingKernel 6be9eec3ef Rare: Offline mode kinda works again 2024-02-12 21:52:08 +02:00
loathingKernel b1e537af43 Library: Initialize only one view on each run
Do not create multiple library views and remove the ability to switch
between them on the fly. Add an option in settings to select the preferred
view. The view will be used the next time Rare is started.
2024-02-12 21:52:08 +02:00
loathingKernel 7c3d5dc9e8 Library: Clean up how changing views works
* Pass python IntEnum objects instead ints for LibraryFilter/Order/View
  We only want to store them as integers to avoid QVariant gibberish

*
2024-02-12 21:52:08 +02:00
loathingKernel 4f4689e82b RareSettings: Add global options for style sheets and color schemes
* Add an option to select the library view mode.
  This will allow us to instantiate only one library view at startup,
  reducing time and complexity of the process.
2024-02-12 21:52:08 +02:00
loathingKernel 3313f15c9f WineResolver: Fix proton command invocation 2024-02-12 21:52:08 +02:00
loathingKernel 52d2ca7cc7 Config: Re-arrange arguments to match the rest of the function signatures 2024-02-12 21:52:08 +02:00
loathingKernel b84686aba6 Overlay: Clean old code 2024-02-12 21:52:08 +02:00
loathingKernel 2d3a8deec1 Rare: Update some strings 2024-02-12 21:52:08 +02:00
loathingKernel 8a3bdbdd91 Rare: Improve translation handling
* Remove base Qt translations from repo, load translations from Qt itself
* Prefix translation `qm` files with `rare_`.
* Rename `translation_source.ts` to simply `source.ts`
* If the selected language matches the system local, remove the option from the configuration.
2024-02-12 21:52:08 +02:00
loathingKernel f088fc95b6 RareApp: Fix wrong logging qualifier 2024-02-12 21:52:08 +02:00
loathingKernel 284543a6d9 Rare: Decouple Rare's locale from legendary's
* Instead of using legendary's locale as fallback, use system't locale
as default.
* Do not hardcode language names and countries but use QLocale on
the translation filenames.
2024-02-12 21:52:08 +02:00
loathingKernel c5c581eb6e Workflows: Use a matrix for upload job 2024-02-12 21:52:08 +02:00
loathingKernel 94030055cf Wrappers: Add a combobox with existing wrappers for the user to choose from.
Add a combobox in the "Add wrapper" dialog, populated with existing
user-defined wrappers from other games.
2024-02-12 21:52:08 +02:00
loathingKernel fb91a55f30 Dialogs: Use consistent dialog titles for the launcher's dialogs 2024-02-12 21:52:08 +02:00
loathingKernel e8e4ed739b Remove some unused imports 2024-02-12 21:52:08 +02:00
loathingKernel 8df9b08e7e WrapperDialog: Use subtitle 2024-02-12 21:52:07 +02:00
loathingKernel a104cf4518 Wrappers: Add WrapperDialog based on ButtonDialog for consistent look and feel 2024-02-12 21:52:07 +02:00
loathingKernel 1cfcb783c2 EnvVars: Add MANGOHUD as read-only 2024-02-12 21:52:07 +02:00
loathingKernel a15a2fbbe2 OverlaySettings: Refactor to be more event-driven complaint 2024-02-12 21:52:07 +02:00
loathingKernel f33c89a411 Library: Fix filtering while searching for games 2024-02-12 21:52:07 +02:00
loathingKernel aadf795d21 Overlays: Reorder method assignments 2024-02-12 21:52:07 +02:00
loathingKernel bb5b0f1585 Overlays: Update title strings 2024-02-12 21:52:07 +02:00
loathingKernel 98213d1ce5 HeadBar: Return LibraryFilter/Order object from current_filter/order instead of int 2024-02-12 21:52:07 +02:00
loathingKernel 49ad79e871 Rare: be more explicit when checking for running platform 2024-02-12 21:52:07 +02:00
loathingKernel 17066f9a67 Rare: cherry-pick some of sourcery suggestions 2024-02-12 21:52:07 +02:00
loathingKernel 8bde2c2c6d Rare: Import platform specific modules only on the relevant platforms 2024-02-12 21:52:07 +02:00
loathingKernel 0ea29bc941 HeadBar: Use int as the data type for the combo boxes and improve checks
Also fix an exception when the setting in the config was faulty we were
looking for the wrong data type in the combobox data.
2024-02-12 21:52:07 +02:00
loathingKernel 0ea4b1a824 Dialogs: Re-implement all dialogs on top of a few common super-classes
Also add a dialog to select optional downloads before verifying
and refactor the move widget into a full-fledged dialog.

To keep dialogs in a common format and allow them to share the same
properties, three classes of dialogs have been implemented inheriting from
each other.

The classes are `BaseDialog` -> `ButtonDialog` -> `ActionDialog`

* Basedialog: is the basis of all dialogs and is responsible for
rejecting close requests from the window manager and the keyboard.
It also restricts access to `exec()` and `exec_()` because they are harmful.
It serves as the basis of Launch and Login dialogs

* ButtonDialog: is offering buttons for accepting or rejecting the presented
option. It implements its own buttons and exposes abstract methods to
implement handling in them. It restricts access to `close()` because these
dialogs should always product a result.
It is the basis of Uninstall, Selective dialogs.

* ActionDialog: in addition to the ButtonDialog, it offers an action buttom
with to validate the form or to make the dialog unable to close. It serves
as the basis of Install and Move dialogs.

Accordingly all dialogs in Rare have been updated to use these classes.
2024-02-12 21:52:07 +02:00
loathingKernel cd1743cb92 GameSettings: Re-strucure settings widgets
The default widgets only implement the settings for the `default` "app_name"
The game specific widgets sub-class the default widgets and implement
whatever they additionally need locally.

Remove multiple calls to save config and save when the game settings gets hidden.
2024-02-12 21:52:07 +02:00
loathingKernel af6d7c5055 Various WIP
* Use `vars()` instead of directly accessing `__dict__`
* Remove `auto_update` from RareGame's metadata
* Correct check for updating the Steam App ID (We want to keep any changes from the user)
* Collect both Wine and Proton prefixes when removing overlay registry keys.
* Add few convenience functions in config_helper and paths.
2024-02-12 21:52:07 +02:00
loathingKernel 7a5bb0b732 RareGame: Use None for unset steam appid. 2024-02-12 21:52:07 +02:00
loathingKernel 36ad33b8f3 SteamGrades: Return the Steam game ID with the grade and store it.
This allows compatibility tools that use the SteamAppId environment
variable to make decisions or apply fixes do their job more accurately.

Also use the stored variable to provide a link to protondb through
the grade label in GameInfo.

The steam grades now use the orjson library to load Steam's ~6MB
database faster.
2024-02-12 21:52:07 +02:00
loathingKernel 07b5d381f0 SideTabContainer: Align top by default 2024-02-12 21:51:42 +02:00
loathingKernel b67c391a26 Chore: fix argument name in keyPressEvent 2024-02-12 21:51:42 +02:00
loathingKernel 9eb5f2c51e Dialogs: Align contents on center when the WM doesn't respect the dialog size
Useful for `gamescope`'s `--force-windows-fullscreen` mode
2024-02-12 21:51:42 +02:00
loathingKernel 88b6e91530 BrowserLogin: Add dedicated application mode to Rare for the webview login page
Add a sub-application to Rare to launch the webview for logging into EGS.
The sub-application operates similatly to the `laucher` sub-application and
it is autonomous. After a successful login in returns the exchange code
to the standard output to be parsed and used by the login dialog.

The reason this implementation was chosen is because when pywebview uses
pyqtwebengine as the GUI library, we cannot launch it through Rare as
it tries to spawn a QMainWindow inside an existing event loop, which is
prohibited by Qt.

Despite that, EGS login page doesn't work correctly with QtWebEngine,
so on linux default to the GTK backend for pywebview, and this change
helps keeping applications using different toolkits separate.

At this moment, spawning the sub-application blocks the execution of the
main application.

This change should make it easier to authenticate through Rare inside
a gamescope session, such as the steam deck.
2024-02-12 21:51:42 +02:00
Stelios Tsampas 9181641d70
Merge pull request #378 from loathingKernel/next
General fixes
2024-01-29 16:36:21 +02:00
loathingKernel 03b9e44b13 Ui: Delete unused pathedit ui 2024-01-29 16:21:32 +02:00
loathingKernel f321736dde GameInfo: Show cover image in color despite installation status 2024-01-29 16:21:19 +02:00
loathingKernel 58574c1977 Legendary: Do a smarter split 2024-01-29 16:20:59 +02:00
loathingKernel fe709f5702 Launcher: Add Homeworld Remastered Collection to the do-not-attach list
Fixes: RareDevs/Rare#376
2024-01-29 16:20:30 +02:00
loathingKernel 1269abf1f7 Rare: Remove dead code from main 2024-01-29 16:19:11 +02:00
loathingKernel 5d2cfbf71a AppImage: Set PYTHONNOUSERSITE 2024-01-29 16:18:57 +02:00
loathingKernel 7cdf7996b2 Update Readme and screenshots 2024-01-29 16:18:46 +02:00
loathingKernel f089703eb5 ListGameWidget: Make Launch/Install buttons larger and the widget itself smaller 2024-01-29 16:18:21 +02:00
loathingKernel da2e1c0d27 ListGameWidget: Fix progress label relocating randomly 2024-01-29 16:18:16 +02:00
loathingKernel 7ef5172d62 ImageWidget: Fix console spam about QPainter not being active 2024-01-27 01:19:51 +02:00
249 changed files with 21538 additions and 14131 deletions

1
.gitattributes vendored
View file

@ -1,3 +1,4 @@
rare/resources/resources.py binary
rare/resources/static_css/__init__.py binary
rare/resources/stylesheets/ChildOfMetropolis/__init__.py binary
rare/resources/stylesheets/RareStyle/__init__.py binary

View file

@ -23,6 +23,7 @@ on:
jobs:
pylint:
strategy:
fail-fast: false
matrix:
os: ['macos-latest', 'windows-latest', 'ubuntu-latest']
version: ['3.9', '3.10', '3.11', '3.12']
@ -47,4 +48,4 @@ jobs:
pip3 install qstylizer
- name: Analysis with pylint
run: |
python3 -m pylint -E rare --jobs=3 --disable=E0611,E1123,E1120 --ignore=ui,singleton.py --extension-pkg-whitelist=PyQt5 --generated-members=PyQt5.*,orjson.*
python3 -m pylint rare

93
.github/workflows/codeql.yml vendored Normal file
View file

@ -0,0 +1,93 @@
# For most projects, this workflow file will not need changing; you simply need
# to commit it to your repository.
#
# You may wish to alter this file to override the set of languages analyzed,
# or to provide custom queries or build logic.
#
# ******** NOTE ********
# We have attempted to detect the languages in your repository. Please check
# the `language` matrix defined below to confirm you have the correct set of
# supported CodeQL languages.
#
name: "CodeQL"
on:
push:
branches: [ "main" ]
pull_request:
branches: [ "main" ]
schedule:
- cron: '37 11 * * 3'
jobs:
analyze:
name: Analyze (${{ matrix.language }})
# Runner size impacts CodeQL analysis time. To learn more, please see:
# - https://gh.io/recommended-hardware-resources-for-running-codeql
# - https://gh.io/supported-runners-and-hardware-resources
# - https://gh.io/using-larger-runners (GitHub.com only)
# Consider using larger runners or machines with greater resources for possible analysis time improvements.
runs-on: ${{ (matrix.language == 'swift' && 'macos-latest') || 'ubuntu-latest' }}
timeout-minutes: ${{ (matrix.language == 'swift' && 120) || 360 }}
permissions:
# required for all workflows
security-events: write
# required to fetch internal or private CodeQL packs
packages: read
# only required for workflows in private repositories
actions: read
contents: read
strategy:
fail-fast: false
matrix:
include:
- language: python
build-mode: none
# CodeQL supports the following values keywords for 'language': 'c-cpp', 'csharp', 'go', 'java-kotlin', 'javascript-typescript', 'python', 'ruby', 'swift'
# Use `c-cpp` to analyze code written in C, C++ or both
# Use 'java-kotlin' to analyze code written in Java, Kotlin or both
# Use 'javascript-typescript' to analyze code written in JavaScript, TypeScript or both
# To learn more about changing the languages that are analyzed or customizing the build mode for your analysis,
# see https://docs.github.com/en/code-security/code-scanning/creating-an-advanced-setup-for-code-scanning/customizing-your-advanced-setup-for-code-scanning.
# If you are analyzing a compiled language, you can modify the 'build-mode' for that language to customize how
# your codebase is analyzed, see https://docs.github.com/en/code-security/code-scanning/creating-an-advanced-setup-for-code-scanning/codeql-code-scanning-for-compiled-languages
steps:
- name: Checkout repository
uses: actions/checkout@v4
# Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL
uses: github/codeql-action/init@v3
with:
languages: ${{ matrix.language }}
build-mode: ${{ matrix.build-mode }}
# If you wish to specify custom queries, you can do so here or in a config file.
# By default, queries listed here will override any specified in a config file.
# Prefix the list here with "+" to use these queries and those in the config file.
# For more details on CodeQL's query packs, refer to: https://docs.github.com/en/code-security/code-scanning/automatically-scanning-your-code-for-vulnerabilities-and-errors/configuring-code-scanning#using-queries-in-ql-packs
# queries: security-extended,security-and-quality
# If the analyze step fails for one of the languages you are analyzing with
# "We were unable to automatically build your code", modify the matrix above
# to set the build mode to "manual" for that language. Then modify this step
# to build your code.
# Command-line programs to run using the OS shell.
# 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun
- if: matrix.build-mode == 'manual'
shell: bash
run: |
echo 'If you are using a "manual" build mode for one or more of the' \
'languages you are analyzing, replace this with the commands to build' \
'your code, for example:'
echo ' make bootstrap'
echo ' make release'
exit 1
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@v3
with:
category: "/language:${{matrix.language}}"

View file

@ -16,7 +16,7 @@ jobs:
- uses: actions/setup-python@v4
with:
cache: pip
python-version: '3.11'
python-version: '3.12'
check-latest: true
architecture: x64
- name: Install Build Dependencies

View file

@ -16,7 +16,7 @@ jobs:
- uses: actions/setup-python@v4
with:
cache: pip
python-version: '3.11'
python-version: '3.12'
check-latest: true
architecture: x64
- name: Install build dependencies

View file

@ -14,39 +14,34 @@ on:
type: string
name2:
type: string
default: ""
file2:
type: string
default: ""
jobs:
release:
name: Upload
runs-on: ubuntu-latest
strategy:
matrix:
include:
- name: ${{ inputs.name1 }}
file: ${{ inputs.file1 }}
- name: ${{ inputs.name2 }}
file: ${{ inputs.file2 }}
steps:
- name: Download ${{ inputs.name1 }} artifact
- name: Download ${{ matrix.name }} from artifact
uses: actions/download-artifact@v3
if: ${{ matrix.name != '' }}
with:
name: ${{ inputs.name1 }}
- name: Upload ${{ inputs.name1 }} to release
name: ${{ matrix.name }}
- name: Upload ${{ matrix.name }} to release
uses: svenstaro/upload-release-action@v2
if: ${{ matrix.name != '' }}
with:
repo_token: ${{ secrets.GITHUB_TOKEN }}
file: ${{ inputs.file1 }}
asset_name: ${{ inputs.name1 }}
file: ${{ matrix.file }}
asset_name: ${{ matrix.name }}
tag: ${{ inputs.version }}
overwrite: true
- name: Download ${{ inputs.name2 }} artifact
uses: actions/download-artifact@v3
if: ${{ inputs.name2 != '' }}
with:
name: ${{ inputs.name2 }}
- name: Upload ${{ inputs.name2 }} to release
uses: svenstaro/upload-release-action@v2
if: ${{ inputs.name2 != '' }}
with:
repo_token: ${{ secrets.GITHUB_TOKEN }}
file: ${{ inputs.file2 }}
asset_name: ${{ inputs.name2 }}
tag: ${{ inputs.version }}
overwrite: true

View file

@ -4,9 +4,9 @@ on:
workflow_call:
outputs:
version:
value: ${{ jobs.version.outputs.tag_abbrev }}.${{ jobs.version.outputs.tag_offset }}
value: "${{ jobs.version.outputs.tag_abbrev }}.${{ jobs.version.outputs.tag_offset }}"
branch:
value: ${{ jobs.version.outputs.branch }}
value: "${{ jobs.version.outputs.branch }}"
jobs:

View file

@ -2,9 +2,9 @@
host = https://www.transifex.com
[o:rare-1:p:rare:r:placeholder-ts]
file_filter = rare/resources/languages/<lang>.ts
source_file = rare/resources/languages/translation_source.ts
source_lang = en_US
file_filter = rare/resources/languages/rare_<lang>.ts
source_file = rare/resources/languages/source.ts
source_lang = en
type = QT
minimum_perc = 50

View file

@ -45,6 +45,7 @@ AppDir:
# Path to the site-packages dir or other modules dirs
# See https://docs.python.org/3/using/cmdline.html#envvar-PYTHONPATH
PYTHONPATH: '${APPDIR}/usr/lib/python3.8/site-packages:${APPDIR}/usr/lib/python3.9/site-packages'
PYTHONNOUSERSITE: 1
test:
fedora:

View file

@ -23,7 +23,7 @@ Rare is a graphical interface for Legendary, a command line alternative to Epic
- Packages, packages everywhere
## Reporing issues
## Reporting issues
If you run into any issues, you can report them by creating an issue on GitHub: https://github.com/RareDevs/Rare/issues/new/choose
@ -147,12 +147,24 @@ Depending on your operating system and the `python` distribution, the following
### Run from source
1. Clone the repo: `git clone https://github.com/Dummerle/Rare
1. Clone the repo: `git clone https://github.com/RareDevs/Rare`
2. Change your working directory to the project folder: `cd Rare`
3. Run `pip install -r requirements.txt` to install all required dependencies.
* If you want to be able to use the automatic login and Discord pypresence, run `pip install -r requirements-full.txt`
* If you are on Arch you can run `sudo pacman --needed -S python-wheel python-setuptools python-pyqt5 python-qtawesome python-requests python-orjson` and `yay -S legendary`
* If you are on FreeBSD you have to install py39-qt5 from the packages: `sudo pkg install py39-qt5`
* If you want to be able to use the automatic login and Discord pypresence, run
```shell
pip install -r requirements-full.txt
```
* If you are on Arch you can run
```shell
sudo pacman --needed -S python-wheel python-setuptools python-pyqt5 python-qtawesome python-requests python-orjson
```
```
yay -S legendary
```
* If you are on FreeBSD you have to install py39-qt5 from the packages
```shell
sudo pkg install py39-qt5
```
4. Run `python3 -m rare`
@ -165,10 +177,16 @@ There are several options to contribute.
More information is available in CONTRIBUTING.md.
## Images
## Screenshots
| Game covers | Vertical list |
|----------------------------------------------|----------------------------------------------|
| ![alt text](Screenshots/RareLibraryIcon.png) | ![alt text](Screenshots/RareLibraryList.png) |
| Game details | Game settings |
|-------------------------------------------|-----------------------------------------------|
| ![alt text](Screenshots/RareGameInfo.png) | ![alt text](Screenshots/RareGameSettings.png) |
| Downloads | Application settings |
|--------------------------------------------|-------------------------------------------|
| ![alt text](Screenshots/RareDownloads.png) | ![alt text](Screenshots/RareSettings.png) |
![alt text](https://github.com/Dummerle/Rare/blob/main/Screenshots/Rare.png?raw=true)
![alt text](https://github.com/Dummerle/Rare/blob/main/Screenshots/GameInfo.png?raw=true)
![alt text](https://github.com/Dummerle/Rare/blob/main/Screenshots/RareSettings.png?raw=true)
![alt text](https://github.com/Dummerle/Rare/blob/main/Screenshots/RareDownloads.png?raw=true)
![alt text](https://github.com/Dummerle/Rare/blob/main/Screenshots/GameSettings.png?raw=true)

Binary file not shown.

Before

Width:  |  Height:  |  Size: 156 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 512 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 54 KiB

After

Width:  |  Height:  |  Size: 93 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 91 KiB

View file

Before

Width:  |  Height:  |  Size: 81 KiB

After

Width:  |  Height:  |  Size: 81 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 358 KiB

View file

@ -2,4 +2,4 @@ import pkg_resources
from subprocess import call
for dist in pkg_resources.working_set:
call("python -m pip install --upgrade " + dist.project_name, shell=True)
call(f"python -m pip install --upgrade {dist.project_name}", shell=True)

View file

@ -3,6 +3,9 @@
cwd="$(pwd)"
cd "$(dirname "$0")"/.. || exit
pylupdate5 -noobsolete $(find rare/ -iname "*.py") -ts rare/resources/languages/translation_source.ts
#py_files=$(find rare -iname "*.py" -not -path rare/ui)
#ui_files=$(find rare/ui -iname "*.ui")
pylupdate5 -noobsolete $(find rare/ -iname "*.py") -ts rare/resources/languages/source.ts
cd "$cwd" || exit

643
pylintrc Normal file
View file

@ -0,0 +1,643 @@
[MAIN]
# Analyse import fallback blocks. This can be used to support both Python 2 and
# 3 compatible code, which means that the block might have code that exists
# only in one or another interpreter, leading to false positives when analysed.
analyse-fallback-blocks=no
# Clear in-memory caches upon conclusion of linting. Useful if running pylint
# in a server-like mode.
clear-cache-post-run=no
# Load and enable all available extensions. Use --list-extensions to see a list
# all available extensions.
#enable-all-extensions=
# In error mode, messages with a category besides ERROR or FATAL are
# suppressed, and no reports are done by default. Error mode is compatible with
# disabling specific errors.
errors-only=yes
# Always return a 0 (non-error) status code, even if lint errors are found.
# This is primarily useful in continuous integration scripts.
#exit-zero=
# A comma-separated list of package or module names from where C extensions may
# be loaded. Extensions are loading into the active Python interpreter and may
# run arbitrary code.
extension-pkg-allow-list=
# A comma-separated list of package or module names from where C extensions may
# be loaded. Extensions are loading into the active Python interpreter and may
# run arbitrary code. (This is an alternative name to extension-pkg-allow-list
# for backward compatibility.)
extension-pkg-whitelist=PyQt5
# Return non-zero exit code if any of these messages/categories are detected,
# even if score is above --fail-under value. Syntax same as enable. Messages
# specified are enabled, while categories only check already-enabled messages.
fail-on=
# Specify a score threshold under which the program will exit with error.
fail-under=10
# Interpret the stdin as a python script, whose filename needs to be passed as
# the module_or_package argument.
#from-stdin=
# Files or directories to be skipped. They should be base names, not paths.
ignore=ui,singleton.py
# Add files or directories matching the regular expressions patterns to the
# ignore-list. The regex matches against paths and can be in Posix or Windows
# format. Because '\\' represents the directory delimiter on Windows systems,
# it can't be used as an escape character.
ignore-paths=
# Files or directories matching the regular expression patterns are skipped.
# The regex matches against base names, not paths. The default value ignores
# Emacs file locks
ignore-patterns=^\.#
# List of module names for which member attributes should not be checked and
# will not be imported (useful for modules/projects where namespaces are
# manipulated during runtime and thus existing member attributes cannot be
# deduced by static analysis). It supports qualified module names, as well as
# Unix pattern matching.
ignored-modules=
# Python code to execute, usually for sys.path manipulation such as
# pygtk.require().
#init-hook=
# Use multiple processes to speed up Pylint. Specifying 0 will auto-detect the
# number of processors available to use, and will cap the count on Windows to
# avoid hangs.
jobs=3
# Control the amount of potential inferred values when inferring a single
# object. This can help the performance when dealing with large functions or
# complex, nested conditions.
limit-inference-results=100
# List of plugins (as comma separated values of python module names) to load,
# usually to register additional checkers.
load-plugins=
# Pickle collected data for later comparisons.
persistent=yes
# Minimum Python version to use for version dependent checks. Will default to
# the version used to run pylint.
py-version=3.9
# Discover python modules and packages in the file system subtree.
recursive=yes
# Add paths to the list of the source roots. Supports globbing patterns. The
# source root is an absolute path or a path relative to the current working
# directory used to determine a package namespace for modules located under the
# source root.
source-roots=
# When enabled, pylint would attempt to guess common misconfiguration and emit
# user-friendly hints instead of false-positive error messages.
suggestion-mode=yes
# Allow loading of arbitrary C extensions. Extensions are imported into the
# active Python interpreter and may run arbitrary code.
unsafe-load-any-extension=no
# In verbose mode, extra non-checker-related info will be displayed.
#verbose=
[BASIC]
# Naming style matching correct argument names.
argument-naming-style=snake_case
# Regular expression matching correct argument names. Overrides argument-
# naming-style. If left empty, argument names will be checked with the set
# naming style.
#argument-rgx=
# Naming style matching correct attribute names.
attr-naming-style=snake_case
# Regular expression matching correct attribute names. Overrides attr-naming-
# style. If left empty, attribute names will be checked with the set naming
# style.
#attr-rgx=
# Bad variable names which should always be refused, separated by a comma.
bad-names=foo,
bar,
baz,
toto,
tutu,
tata
# Bad variable names regexes, separated by a comma. If names match any regex,
# they will always be refused
bad-names-rgxs=
# Naming style matching correct class attribute names.
class-attribute-naming-style=any
# Regular expression matching correct class attribute names. Overrides class-
# attribute-naming-style. If left empty, class attribute names will be checked
# with the set naming style.
#class-attribute-rgx=
# Naming style matching correct class constant names.
class-const-naming-style=UPPER_CASE
# Regular expression matching correct class constant names. Overrides class-
# const-naming-style. If left empty, class constant names will be checked with
# the set naming style.
#class-const-rgx=
# Naming style matching correct class names.
class-naming-style=PascalCase
# Regular expression matching correct class names. Overrides class-naming-
# style. If left empty, class names will be checked with the set naming style.
#class-rgx=
# Naming style matching correct constant names.
const-naming-style=UPPER_CASE
# Regular expression matching correct constant names. Overrides const-naming-
# style. If left empty, constant names will be checked with the set naming
# style.
#const-rgx=
# Minimum line length for functions/classes that require docstrings, shorter
# ones are exempt.
docstring-min-length=-1
# Naming style matching correct function names.
function-naming-style=snake_case
# Regular expression matching correct function names. Overrides function-
# naming-style. If left empty, function names will be checked with the set
# naming style.
#function-rgx=
# Good variable names which should always be accepted, separated by a comma.
good-names=i,
j,
k,
ex,
Run,
_
# Good variable names regexes, separated by a comma. If names match any regex,
# they will always be accepted
good-names-rgxs=
# Include a hint for the correct naming format with invalid-name.
include-naming-hint=no
# Naming style matching correct inline iteration names.
inlinevar-naming-style=any
# Regular expression matching correct inline iteration names. Overrides
# inlinevar-naming-style. If left empty, inline iteration names will be checked
# with the set naming style.
#inlinevar-rgx=
# Naming style matching correct method names.
method-naming-style=snake_case
# Regular expression matching correct method names. Overrides method-naming-
# style. If left empty, method names will be checked with the set naming style.
#method-rgx=
# Naming style matching correct module names.
module-naming-style=snake_case
# Regular expression matching correct module names. Overrides module-naming-
# style. If left empty, module names will be checked with the set naming style.
#module-rgx=
# Colon-delimited sets of names that determine each other's naming style when
# the name regexes allow several styles.
name-group=
# Regular expression which should only match function or class names that do
# not require a docstring.
no-docstring-rgx=^_
# List of decorators that produce properties, such as abc.abstractproperty. Add
# to this list to register other decorators that produce valid properties.
# These decorators are taken in consideration only for invalid-name.
property-classes=abc.abstractproperty
# Regular expression matching correct type alias names. If left empty, type
# alias names will be checked with the set naming style.
#typealias-rgx=
# Regular expression matching correct type variable names. If left empty, type
# variable names will be checked with the set naming style.
#typevar-rgx=
# Naming style matching correct variable names.
variable-naming-style=snake_case
# Regular expression matching correct variable names. Overrides variable-
# naming-style. If left empty, variable names will be checked with the set
# naming style.
#variable-rgx=
[CLASSES]
# Warn about protected attribute access inside special methods
check-protected-access-in-special-methods=no
# List of method names used to declare (i.e. assign) instance attributes.
defining-attr-methods=__init__,
__new__,
setUp,
asyncSetUp,
__post_init__
# List of member names, which should be excluded from the protected access
# warning.
exclude-protected=_asdict,_fields,_replace,_source,_make,os._exit
# List of valid names for the first argument in a class method.
valid-classmethod-first-arg=cls
# List of valid names for the first argument in a metaclass class method.
valid-metaclass-classmethod-first-arg=mcs
[DESIGN]
# List of regular expressions of class ancestor names to ignore when counting
# public methods (see R0903)
exclude-too-few-public-methods=
# List of qualified class names to ignore when counting class parents (see
# R0901)
ignored-parents=
# Maximum number of arguments for function / method.
max-args=5
# Maximum number of attributes for a class (see R0902).
max-attributes=7
# Maximum number of boolean expressions in an if statement (see R0916).
max-bool-expr=5
# Maximum number of branch for function / method body.
max-branches=12
# Maximum number of locals for function / method body.
max-locals=15
# Maximum number of parents for a class (see R0901).
max-parents=7
# Maximum number of public methods for a class (see R0904).
max-public-methods=20
# Maximum number of return / yield for function / method body.
max-returns=6
# Maximum number of statements in function / method body.
max-statements=50
# Minimum number of public methods for a class (see R0903).
min-public-methods=2
[EXCEPTIONS]
# Exceptions that will emit a warning when caught.
overgeneral-exceptions=builtins.BaseException,builtins.Exception
[FORMAT]
# Expected format of line ending, e.g. empty (any line ending), LF or CRLF.
expected-line-ending-format=
# Regexp for a line that is allowed to be longer than the limit.
ignore-long-lines=^\s*(# )?<?https?://\S+>?$
# Number of spaces of indent required inside a hanging or continued line.
indent-after-paren=4
# String used as indentation unit. This is usually " " (4 spaces) or "\t" (1
# tab).
indent-string=' '
# Maximum number of characters on a single line.
max-line-length=100
# Maximum number of lines in a module.
max-module-lines=1000
# Allow the body of a class to be on the same line as the declaration if body
# contains single statement.
single-line-class-stmt=no
# Allow the body of an if to be on the same line as the test if there is no
# else.
single-line-if-stmt=no
[IMPORTS]
# List of modules that can be imported at any level, not just the top level
# one.
allow-any-import-level=
# Allow explicit reexports by alias from a package __init__.
allow-reexport-from-package=no
# Allow wildcard imports from modules that define __all__.
allow-wildcard-with-all=no
# Deprecated modules which should not be used, separated by a comma.
deprecated-modules=
# Output a graph (.gv or any supported image format) of external dependencies
# to the given file (report RP0402 must not be disabled).
ext-import-graph=
# Output a graph (.gv or any supported image format) of all (i.e. internal and
# external) dependencies to the given file (report RP0402 must not be
# disabled).
import-graph=
# Output a graph (.gv or any supported image format) of internal dependencies
# to the given file (report RP0402 must not be disabled).
int-import-graph=
# Force import order to recognize a module as part of the standard
# compatibility libraries.
known-standard-library=
# Force import order to recognize a module as part of a third party library.
known-third-party=enchant
# Couples of modules and preferred modules, separated by a comma.
preferred-modules=
[LOGGING]
# The type of string formatting that logging methods do. `old` means using %
# formatting, `new` is for `{}` formatting.
logging-format-style=old
# Logging modules to check that the string format arguments are in logging
# function parameter format.
logging-modules=logging
[MESSAGES CONTROL]
# Only show warnings with the listed confidence levels. Leave empty to show
# all. Valid levels: HIGH, CONTROL_FLOW, INFERENCE, INFERENCE_FAILURE,
# UNDEFINED.
confidence=HIGH,
CONTROL_FLOW,
INFERENCE,
INFERENCE_FAILURE,
UNDEFINED
# Disable the message, report, category or checker with the given id(s). You
# can either give multiple identifiers separated by comma (,) or put this
# option multiple times (only on the command line, not in the configuration
# file where it should appear only once). You can also use "--disable=all" to
# disable everything first and then re-enable specific checks. For example, if
# you want to run only the similarities checker, you can use "--disable=all
# --enable=similarities". If you want to run only the classes checker, but have
# no Warning level messages displayed, use "--disable=all --enable=classes
# --disable=W".
disable=raw-checker-failed,
bad-inline-option,
locally-disabled,
file-ignored,
suppressed-message,
useless-suppression,
deprecated-pragma,
use-symbolic-message-instead,
use-implicit-booleaness-not-comparison-to-string,
use-implicit-booleaness-not-comparison-to-zero,
no-name-in-module,
unexpected-keyword-arg,
no-value-for-parameter
# Enable the message, report, category or checker with the given id(s). You can
# either give multiple identifier separated by comma (,) or put this option
# multiple time (only on the command line, not in the configuration file where
# it should appear only once). See also the "--disable" option for examples.
enable=
[METHOD_ARGS]
# List of qualified names (i.e., library.method) which require a timeout
# parameter e.g. 'requests.api.get,requests.api.post'
timeout-methods=requests.api.delete,requests.api.get,requests.api.head,requests.api.options,requests.api.patch,requests.api.post,requests.api.put,requests.api.request
[MISCELLANEOUS]
# List of note tags to take in consideration, separated by a comma.
notes=FIXME,
XXX,
TODO
# Regular expression of note tags to take in consideration.
notes-rgx=
[REFACTORING]
# Maximum number of nested blocks for function / method body
max-nested-blocks=5
# Complete name of functions that never returns. When checking for
# inconsistent-return-statements if a never returning function is called then
# it will be considered as an explicit return statement and no message will be
# printed.
never-returning-functions=sys.exit,argparse.parse_error
# Let 'consider-using-join' be raised when the separator to join on would be
# non-empty (resulting in expected fixes of the type: ``"- " + " -
# ".join(items)``)
suggest-join-with-non-empty-separator=yes
[REPORTS]
# Python expression which should return a score less than or equal to 10. You
# have access to the variables 'fatal', 'error', 'warning', 'refactor',
# 'convention', and 'info' which contain the number of messages in each
# category, as well as 'statement' which is the total number of statements
# analyzed. This score is used by the global evaluation report (RP0004).
evaluation=max(0, 0 if fatal else 10.0 - ((float(5 * error + warning + refactor + convention) / statement) * 10))
# Template used to display messages. This is a python new-style format string
# used to format the message information. See doc for all details.
msg-template=
# Set the output format. Available formats are: text, parseable, colorized,
# json2 (improved json format), json (old json format) and msvs (visual
# studio). You can also give a reporter class, e.g.
# mypackage.mymodule.MyReporterClass.
#output-format=
# Tells whether to display a full report or only the messages.
reports=no
# Activate the evaluation score.
score=yes
[SIMILARITIES]
# Comments are removed from the similarity computation
ignore-comments=yes
# Docstrings are removed from the similarity computation
ignore-docstrings=yes
# Imports are removed from the similarity computation
ignore-imports=yes
# Signatures are removed from the similarity computation
ignore-signatures=yes
# Minimum lines number of a similarity.
min-similarity-lines=4
[SPELLING]
# Limits count of emitted suggestions for spelling mistakes.
max-spelling-suggestions=4
# Spelling dictionary name. No available dictionaries : You need to install
# both the python package and the system dependency for enchant to work.
spelling-dict=
# List of comma separated words that should be considered directives if they
# appear at the beginning of a comment and should not be checked.
spelling-ignore-comment-directives=fmt: on,fmt: off,noqa:,noqa,nosec,isort:skip,mypy:
# List of comma separated words that should not be checked.
spelling-ignore-words=
# A path to a file that contains the private dictionary; one word per line.
spelling-private-dict-file=
# Tells whether to store unknown words to the private dictionary (see the
# --spelling-private-dict-file option) instead of raising a message.
spelling-store-unknown-words=no
[STRING]
# This flag controls whether inconsistent-quotes generates a warning when the
# character used as a quote delimiter is used inconsistently within a module.
check-quote-consistency=no
# This flag controls whether the implicit-str-concat should generate a warning
# on implicit string concatenation in sequences defined over several lines.
check-str-concat-over-line-jumps=no
[TYPECHECK]
# List of decorators that produce context managers, such as
# contextlib.contextmanager. Add to this list to register other decorators that
# produce valid context managers.
contextmanager-decorators=contextlib.contextmanager
# List of members which are set dynamically and missed by pylint inference
# system, and so shouldn't trigger E1101 when accessed. Python regular
# expressions are accepted.
generated-members=PyQt5.*,orjson.*
# Tells whether to warn about missing members when the owner of the attribute
# is inferred to be None.
ignore-none=yes
# This flag controls whether pylint should warn about no-member and similar
# checks whenever an opaque object is returned when inferring. The inference
# can return multiple potential results while evaluating a Python object, but
# some branches might not be evaluated, which results in partial inference. In
# that case, it might be useful to still emit no-member and other checks for
# the rest of the inferred objects.
ignore-on-opaque-inference=yes
# List of symbolic message names to ignore for Mixin members.
ignored-checks-for-mixins=no-member,
not-async-context-manager,
not-context-manager,
attribute-defined-outside-init
# List of class names for which member attributes should not be checked (useful
# for classes with dynamically set attributes). This supports the use of
# qualified names.
ignored-classes=optparse.Values,thread._local,_thread._local,argparse.Namespace
# Show a hint with possible names when a member name was not found. The aspect
# of finding the hint is based on edit distance.
missing-member-hint=yes
# The minimum edit distance a name should have in order to be considered a
# similar match for a missing member name.
missing-member-hint-distance=1
# The total number of similar names that should be taken in consideration when
# showing a hint for a missing member.
missing-member-max-choices=1
# Regex pattern to define which classes are considered mixins.
mixin-class-rgx=.*[Mm]ixin
# List of decorators that change the signature of a decorated function.
signature-mutators=
[VARIABLES]
# List of additional names supposed to be defined in builtins. Remember that
# you should avoid defining new builtins when possible.
additional-builtins=
# Tells whether unused global variables should be treated as a violation.
allow-global-unused-variables=yes
# List of names allowed to shadow builtins
allowed-redefined-builtins=
# List of strings which can identify a callback function by name. A callback
# name must start or end with one of those strings.
callbacks=cb_,
_cb
# A regular expression matching the name of dummy variables (i.e. expected to
# not be used).
dummy-variables-rgx=_+$|(_[a-zA-Z0-9_]*[a-zA-Z0-9]+?$)|dummy|^ignored_|^unused_
# Argument names that match this expression will be ignored.
ignored-argument-names=_.*|^ignored_|^unused_
# Tells whether we should check for unused import in __init__ files.
init-import=no
# List of qualified module names which can have objects that can redefine
# builtins.
redefining-builtins-modules=six.moves,past.builtins,future.builtins,builtins,io

View file

@ -1,5 +1,6 @@
import json
import platform
import shlex
import subprocess
import time
import traceback
@ -17,6 +18,7 @@ from legendary.models.game import SaveGameStatus
from rare.lgndr.core import LegendaryCore
from rare.models.base_game import RareGameSlim
from rare.models.launcher import ErrorModel, Actions, FinishedModel, BaseModel, StateChangedModel
from rare.models.options import options
from rare.widgets.rare_app import RareApp, RareAppException
from .cloud_sync_dialog import CloudSyncDialog, CloudSyncDialogResult
from .console_dialog import ConsoleDialog
@ -29,18 +31,19 @@ DETACHED_APP_NAMES = {
"09e442f830a341f698b4da42abd98c9b", # Fortnite Festival
"d8f7763e07d74c209d760a679f9ed6ac", # Lego Fortnite
"Fortnite_Studio", # Unreal Editor for Fortnite
"dcfccf8d965a4f2281dddf9fead042de", # Homeworld Remastered Collection (issue#376)
}
class PreLaunchThread(QRunnable):
class PreLaunch(QRunnable):
class Signals(QObject):
ready_to_launch = pyqtSignal(LaunchArgs)
started_pre_launch_command = pyqtSignal()
pre_launch_command_started = pyqtSignal()
pre_launch_command_finished = pyqtSignal(int) # exit_code
error_occurred = pyqtSignal(str)
def __init__(self, core: LegendaryCore, args: InitArgs, rgame: RareGameSlim, sync_action=None):
super(PreLaunchThread, self).__init__()
def __init__(self, args: InitArgs, rgame: RareGameSlim, sync_action=None):
super(PreLaunch, self).__init__()
self.signals = self.Signals()
self.logger = getLogger(type(self).__name__)
self.args = args
@ -73,8 +76,10 @@ class PreLaunchThread(QRunnable):
if launch_args.pre_launch_command:
proc = get_configured_process()
proc.setProcessEnvironment(launch_args.environment)
self.signals.started_pre_launch_command.emit()
proc.start(launch_args.pre_launch_command[0], launch_args.pre_launch_command[1:])
self.signals.pre_launch_command_started.emit()
pre_launch_command = shlex.split(launch_args.pre_launch_command)
# self.logger.debug("Executing prelaunch command %s, %s", pre_launch_command[0], pre_launch_command[1:])
proc.start(pre_launch_command[0], pre_launch_command[1:])
if launch_args.pre_launch_wait:
proc.waitForFinished(-1)
return launch_args
@ -141,11 +146,11 @@ class RareLauncher(RareApp):
return
self.rgame = RareGameSlim(self.core, game)
lang = self.settings.value("language", self.core.language_code, type=str)
self.load_translator(lang)
language = self.settings.value(*options.language)
self.load_translator(language)
if QSettings().value("show_console", False, bool):
self.console = ConsoleDialog()
if QSettings(self).value(*options.log_games):
self.console = ConsoleDialog(game.app_title)
self.console.show()
self.game_process.finished.connect(self.__process_finished)
@ -169,13 +174,13 @@ class RareLauncher(RareApp):
@pyqtSlot()
def __proc_log_stdout(self):
self.console.log(
self.console.log_stdout(
self.game_process.readAllStandardOutput().data().decode("utf-8", "ignore")
)
@pyqtSlot()
def __proc_log_stderr(self):
self.console.error(
self.console.log_stderr(
self.game_process.readAllStandardError().data().decode("utf-8", "ignore")
)
@ -293,10 +298,10 @@ class RareLauncher(RareApp):
))
if self.rgame.app_name in DETACHED_APP_NAMES and platform.system() == "Windows":
self.game_process.deleteLater()
subprocess.Popen([args.executable] + args.arguments, cwd=args.working_directory,
env={i: args.environment.value(i) for i in args.environment.keys()})
if self.console:
self.console.log("Launching game as a detached process")
subprocess.Popen([args.executable] + args.arguments, cwd=args.working_directory,
env={i: args.environment.value(i) for i in args.environment.keys()})
self.stop()
return
if self.args.dry_run:
@ -308,6 +313,7 @@ class RareLauncher(RareApp):
print(args.executable, " ".join(args.arguments))
self.stop()
return
# self.logger.debug("Executing prelaunch command %s, %s", args.executable, args.arguments)
self.game_process.start(args.executable, args.arguments)
def error_occurred(self, error_str: str):
@ -321,7 +327,7 @@ class RareLauncher(RareApp):
self.stop()
def start_prepare(self, sync_action=None):
worker = PreLaunchThread(self.core, self.args, self.rgame, sync_action)
worker = PreLaunch(self.args, self.rgame, sync_action)
worker.signals.ready_to_launch.connect(self.launch_game)
worker.signals.error_occurred.connect(self.error_occurred)
# worker.signals.started_pre_launch_command(None)

View file

@ -9,8 +9,8 @@ from legendary.core import LegendaryCore
from legendary.models.game import InstalledGame
from rare.ui.components.tabs.games.game_info.cloud_sync_widget import Ui_CloudSyncWidget
from rare.utils.misc import icon
from rare.widgets.dialogs import ButtonDialog, dialog_title_game
from rare.utils.misc import qta_icon
from rare.widgets.dialogs import ButtonDialog, game_title
logger = getLogger("CloudSyncDialog")
@ -28,9 +28,9 @@ class CloudSyncDialog(ButtonDialog):
def __init__(self, igame: InstalledGame, dt_local: datetime, dt_remote: datetime, parent=None):
super(CloudSyncDialog, self).__init__(parent=parent)
header = self.tr("Cloud saves for")
self.setWindowTitle(dialog_title_game(header, igame.title))
self.setWindowTitle(game_title(header, igame.title))
title_label = QLabel(f"<h4>{dialog_title_game(header, igame.title)}</h4>", self)
title_label = QLabel(f"<h4>{game_title(header, igame.title)}</h4>", self)
sync_widget = QWidget(self)
self.sync_ui = Ui_CloudSyncWidget()
@ -41,7 +41,7 @@ class CloudSyncDialog(ButtonDialog):
layout.addWidget(sync_widget)
self.accept_button.setText(self.tr("Skip"))
self.accept_button.setIcon(icon("fa.chevron-right"))
self.accept_button.setIcon(qta_icon("fa.chevron-right"))
self.setCentralLayout(layout)
@ -62,8 +62,8 @@ class CloudSyncDialog(ButtonDialog):
self.sync_ui.date_info_local.setText(dt_local.strftime("%A, %d. %B %Y %X") if dt_local else "None")
self.sync_ui.date_info_remote.setText(dt_remote.strftime("%A, %d. %B %Y %X") if dt_remote else "None")
self.sync_ui.icon_local.setPixmap(icon("mdi.harddisk", "fa.desktop").pixmap(128, 128))
self.sync_ui.icon_remote.setPixmap(icon("mdi.cloud-outline", "ei.cloud").pixmap(128, 128))
self.sync_ui.icon_local.setPixmap(qta_icon("mdi.harddisk", "fa.desktop").pixmap(128, 128))
self.sync_ui.icon_remote.setPixmap(qta_icon("mdi.cloud-outline", "ei.cloud").pixmap(128, 128))
self.sync_ui.upload_button.clicked.connect(self.__on_upload)
self.sync_ui.download_button.clicked.connect(self.__on_download)

View file

@ -1,4 +1,4 @@
import platform
import os
from typing import Union
from PyQt5.QtCore import QProcessEnvironment, pyqtSignal, QSize, Qt
@ -14,7 +14,8 @@ from PyQt5.QtWidgets import (
QSizePolicy, QTableWidgetItem, QHeaderView, QApplication,
)
from rare.ui.launcher.console_env import Ui_ConsoleEnv
from rare.ui.commands.launcher.console_env import Ui_ConsoleEnv
from rare.widgets.dialogs import dialog_title, game_title
class ConsoleDialog(QDialog):
@ -22,15 +23,17 @@ class ConsoleDialog(QDialog):
kill = pyqtSignal()
env: QProcessEnvironment
def __init__(self, parent=None):
def __init__(self, app_title: str, parent=None):
super(ConsoleDialog, self).__init__(parent=parent)
self.setAttribute(Qt.WA_DeleteOnClose, True)
self.setWindowTitle("Rare - Console")
self.setWindowTitle(
dialog_title(game_title(self.tr("Console"), app_title))
)
self.setGeometry(0, 0, 640, 480)
layout = QVBoxLayout()
self.console = ConsoleEdit(self)
layout.addWidget(self.console)
self.console_edit = ConsoleEdit(self)
layout.addWidget(self.console_edit)
button_layout = QHBoxLayout()
@ -44,7 +47,7 @@ class ConsoleDialog(QDialog):
self.clear_button = QPushButton(self.tr("Clear console"))
button_layout.addWidget(self.clear_button)
self.clear_button.clicked.connect(self.console.clear)
self.clear_button.clicked.connect(self.console_edit.clear)
button_layout.addItem(QSpacerItem(0, 0, QSizePolicy.Expanding, QSizePolicy.Fixed))
@ -62,7 +65,7 @@ class ConsoleDialog(QDialog):
self.setLayout(layout)
self.env_variables = ConsoleEnv(self)
self.env_variables = ConsoleEnv(app_title, self)
self.env_variables.hide()
self.accept_close = False
@ -100,7 +103,7 @@ class ConsoleDialog(QDialog):
if "." not in file:
file += ".log"
with open(file, "w") as f:
f.write(self.console.toPlainText())
f.write(self.console_edit.toPlainText())
f.close()
self.save_button.setText(self.tr("Saved"))
@ -111,15 +114,21 @@ class ConsoleDialog(QDialog):
self.env_variables.setTable(self.env)
self.env_variables.show()
def log(self, text: str, end: str = "\n"):
self.console.log(text + end)
def log(self, text: str):
self.console_edit.log(f"Rare: {text}")
def error(self, text, end: str = "\n"):
self.console.error(text + end)
def log_stdout(self, text: str):
self.console_edit.log(text)
def error(self, text):
self.console_edit.error(f"Rare: {text}")
def log_stderr(self, text):
self.console_edit.error(text)
def on_process_exit(self, app_title: str, status: Union[int, str]):
self.error(
self.tr("Application \"{}\" finished with \"{}\"\n").format(app_title, status)
self.tr("Application finished with exit code \"{}\"").format(status)
)
self.accept_close = True
@ -140,11 +149,14 @@ class ConsoleDialog(QDialog):
class ConsoleEnv(QDialog):
def __init__(self, parent=None):
def __init__(self, app_title: str, parent=None):
super(ConsoleEnv, self).__init__(parent=parent)
self.setAttribute(Qt.WA_DeleteOnClose, False)
self.ui = Ui_ConsoleEnv()
self.ui.setupUi(self)
self.setWindowTitle(
dialog_title(game_title(self.tr("Environment"), app_title))
)
def setTable(self, env: QProcessEnvironment):
self.ui.table.clearContents()
@ -165,17 +177,6 @@ class ConsoleEdit(QPlainTextEdit):
font = QFont("Monospace")
font.setStyleHint(QFont.Monospace)
self.setFont(font)
self._cursor_output = self.textCursor()
def log(self, text):
html = f"<p style=\"color:#BBB;white-space:pre\">{text}</p>"
self._cursor_output.insertHtml(html)
self.scroll_to_last_line()
def error(self, text):
html = f"<p style=\"color:#eee;white-space:pre\">{text}</p>"
self._cursor_output.insertHtml(html)
self.scroll_to_last_line()
def scroll_to_last_line(self):
cursor = self.textCursor()
@ -184,3 +185,14 @@ class ConsoleEdit(QPlainTextEdit):
QTextCursor.Up if cursor.atBlockStart() else QTextCursor.StartOfLine
)
self.setTextCursor(cursor)
def print_to_console(self, text: str, color: str):
html = f"<p style=\"color:{color};white-space:pre\">{text}</p>"
self.appendHtml(html)
self.scroll_to_last_line()
def log(self, text):
self.print_to_console(text, "#aaa")
def error(self, text):
self.print_to_console(text, "#a33")

View file

@ -11,7 +11,7 @@ from legendary.models.game import LaunchParameters
from rare.models.base_game import RareGameSlim
logger = getLogger("Helper")
logger = getLogger("RareLauncherHelper")
class GameArgsError(Exception):
@ -156,7 +156,7 @@ def get_launch_args(rgame: RareGameSlim, init_args: InitArgs = None) -> LaunchAr
if not rgame.is_installed:
raise GameArgsError("Game is not installed or has unsupported format")
if rgame.is_dlc:
if rgame.is_dlc and not rgame.is_launchable_addon:
raise GameArgsError("Game is a DLC")
if not os.path.exists(rgame.install_path):
raise GameArgsError("Game path does not exist")

13
rare/commands/webview.py Normal file
View file

@ -0,0 +1,13 @@
import sys
from argparse import Namespace
from legendary.utils import webview_login
def launch(args: Namespace) -> int:
if webview_login.do_webview_login(
callback_code=sys.stdout.write, user_agent=f'EpicGamesLauncher/{args.egl_version}'
):
return 0
else:
return 1

View file

@ -9,6 +9,7 @@ from PyQt5.QtCore import QThreadPool, QTimer, pyqtSlot, Qt
from PyQt5.QtWidgets import QApplication, QMessageBox
from requests import HTTPError
from rare.models.options import options
from rare.components.dialogs.launch_dialog import LaunchDialog
from rare.components.main_window import MainWindow
from rare.shared import RareCore
@ -45,8 +46,8 @@ class Rare(RareApp):
self.signals = RareCore.instance().signals()
self.core = RareCore.instance().core()
lang = self.settings.value("language", self.core.language_code, type=str)
self.load_translator(lang)
language = self.settings.value(*options.language)
self.load_translator(language)
# set Application name for settings
self.main_window: Optional[MainWindow] = None

View file

@ -13,9 +13,9 @@ from rare.models.install import InstallDownloadModel, InstallQueueItemModel, Ins
from rare.shared.workers.install_info import InstallInfoWorker
from rare.ui.components.dialogs.install_dialog import Ui_InstallDialog
from rare.ui.components.dialogs.install_dialog_advanced import Ui_InstallDialogAdvanced
from rare.utils.misc import format_size, icon
from rare.utils.misc import format_size, qta_icon
from rare.widgets.collapsible_widget import CollapsibleFrame
from rare.widgets.dialogs import ActionDialog, dialog_title_game
from rare.widgets.dialogs import ActionDialog, game_title
from rare.widgets.indicator_edit import PathEdit, IndicatorReasonsCommon
from rare.widgets.selective_widget import SelectiveWidget
@ -63,19 +63,19 @@ class InstallDialog(ActionDialog):
super(InstallDialog, self).__init__(parent=parent)
header = self.tr("Install")
bicon = icon("ri.install-line")
bicon = qta_icon("ri.install-line")
if options.repair_mode:
header = self.tr("Repair")
bicon = icon("fa.wrench")
bicon = qta_icon("fa.wrench")
if options.repair_and_update:
header = self.tr("Repair and update")
elif options.update:
header = self.tr("Update")
elif options.reset_sdl:
header = self.tr("Modify")
bicon = icon("fa.gear")
self.setWindowTitle(dialog_title_game(header, rgame.app_title))
self.setSubtitle(dialog_title_game(header, rgame.app_title))
bicon = qta_icon("fa.gear")
self.setWindowTitle(game_title(header, rgame.app_title))
self.setSubtitle(game_title(header, rgame.app_title))
install_widget = QWidget(self)
self.ui = Ui_InstallDialog()
@ -198,7 +198,7 @@ class InstallDialog(ActionDialog):
self.accept_button.setObjectName("InstallButton")
self.action_button.setText(self.tr("Verify"))
self.action_button.setIcon(icon("fa.check"))
self.action_button.setIcon(qta_icon("fa.check"))
self.setCentralWidget(install_widget)
@ -258,10 +258,12 @@ class InstallDialog(ActionDialog):
def action_handler(self):
self.error_box()
message = self.tr("Updating...")
font = self.font()
font.setItalic(True)
self.ui.download_size_text.setText(message)
self.ui.download_size_text.setStyleSheet("font-style: italic; font-weight: normal")
self.ui.download_size_text.setFont(font)
self.ui.install_size_text.setText(message)
self.ui.install_size_text.setStyleSheet("font-style: italic; font-weight: normal")
self.ui.install_size_text.setFont(font)
self.setActive(True)
self.options_changed = False
self.get_options()
@ -309,15 +311,19 @@ class InstallDialog(ActionDialog):
download_size = download.analysis.dl_size
install_size = download.analysis.install_size
# install_size = self.dl_item.download.analysis.disk_space_delta
bold_font = self.font()
bold_font.setBold(True)
italic_font = self.font()
italic_font.setItalic(True)
if download_size or (not download_size and (download.game.is_dlc or download.repair)):
self.ui.download_size_text.setText(format_size(download_size))
self.ui.download_size_text.setStyleSheet("font-style: normal; font-weight: bold")
self.ui.download_size_text.setFont(bold_font)
self.accept_button.setEnabled(not self.options_changed)
else:
self.ui.install_size_text.setText(self.tr("Game already installed"))
self.ui.install_size_text.setStyleSheet("font-style: italics; font-weight: normal")
self.ui.download_size_text.setText(self.tr("Game already installed"))
self.ui.download_size_text.setFont(italic_font)
self.ui.install_size_text.setText(format_size(install_size))
self.ui.install_size_text.setStyleSheet("font-style: normal; font-weight: bold")
self.ui.install_size_text.setFont(bold_font)
self.action_button.setEnabled(self.options_changed)
has_prereqs = bool(download.igame.prereq_info) and not download.igame.prereq_info.get("installed", False)
if has_prereqs:

View file

@ -7,7 +7,7 @@ from legendary.core import LegendaryCore
from rare.shared import ArgumentsSingleton
from rare.ui.components.dialogs.login.landing_page import Ui_LandingPage
from rare.ui.components.dialogs.login.login_dialog import Ui_LoginDialog
from rare.utils.misc import icon
from rare.utils.misc import qta_icon
from rare.widgets.dialogs import BaseDialog
from rare.widgets.sliding_stack import SlidingStackedWidget
from .browser_login import BrowserLogin
@ -99,9 +99,9 @@ class LoginDialog(BaseDialog):
self.login_stack.setCurrentWidget(self.landing_page)
self.ui.exit_button.setIcon(icon("fa.remove"))
self.ui.back_button.setIcon(icon("fa.chevron-left"))
self.ui.next_button.setIcon(icon("fa.chevron-right"))
self.ui.exit_button.setIcon(qta_icon("fa.remove"))
self.ui.back_button.setIcon(qta_icon("fa.chevron-left"))
self.ui.next_button.setIcon(qta_icon("fa.chevron-right"))
# lk: Set next as the default button only to stop closing the dialog when pressing enter
self.ui.exit_button.setAutoDefault(False)

View file

@ -2,14 +2,15 @@ import json
from logging import getLogger
from typing import Tuple
from PyQt5.QtCore import pyqtSignal, QUrl
from PyQt5.QtCore import pyqtSignal, QUrl, QProcess, pyqtSlot
from PyQt5.QtGui import QDesktopServices
from PyQt5.QtWidgets import QFrame, qApp, QFormLayout, QLineEdit
from legendary.core import LegendaryCore
from legendary.utils import webview_login
from rare.lgndr.core import LegendaryCore
from rare.ui.components.dialogs.login.browser_login import Ui_BrowserLogin
from rare.utils.misc import icon
from rare.utils.misc import qta_icon
from rare.utils.paths import get_rare_executable
from rare.widgets.indicator_edit import IndicatorLineEdit, IndicatorReasonsCommon
logger = getLogger("BrowserLogin")
@ -33,7 +34,7 @@ class BrowserLogin(QFrame):
)
self.sid_edit.line_edit.setEchoMode(QLineEdit.Password)
self.ui.link_text.setText(self.login_url)
self.ui.copy_button.setIcon(icon("mdi.content-copy", "fa.copy"))
self.ui.copy_button.setIcon(qta_icon("mdi.content-copy", "fa.copy"))
self.ui.copy_button.clicked.connect(self.copy_link)
self.ui.form_layout.setWidget(
self.ui.form_layout.getWidgetPosition(self.ui.sid_label)[0],
@ -43,6 +44,7 @@ class BrowserLogin(QFrame):
self.ui.open_button.clicked.connect(self.open_browser)
self.sid_edit.textChanged.connect(self.changed.emit)
@pyqtSlot()
def copy_link(self):
clipboard = qApp.clipboard()
clipboard.setText(self.login_url)
@ -79,12 +81,24 @@ class BrowserLogin(QFrame):
except Exception as e:
logger.warning(e)
@pyqtSlot()
def open_browser(self):
if not webview_login.webview_available:
logger.warning("You don't have webengine installed, you will need to manually copy the authorizationCode.")
QDesktopServices.openUrl(QUrl(self.login_url))
else:
if webview_login.do_webview_login(callback_code=self.core.auth_ex_token):
cmd = get_rare_executable() + ["login", self.core.get_egl_version()]
proc = QProcess(self)
proc.start(cmd[0], cmd[1:])
proc.waitForFinished(-1)
out, err = (
proc.readAllStandardOutput().data().decode("utf-8", "ignore"),
proc.readAllStandardError().data().decode("utf-8", "ignore")
)
proc.deleteLater()
if out:
self.core.auth_ex_token(out)
logger.info("Successfully logged in as %s", {self.core.lgd.userdata['displayName']})
self.success.emit()
else:

View file

@ -54,7 +54,7 @@ class ImportLogin(QFrame):
else:
self.ui.status_label.setText(self.text_egl_notfound)
self.ui.prefix_tool.clicked.connect(self.prefix_path)
self.ui.prefix_button.clicked.connect(self.prefix_path)
self.ui.prefix_combo.editTextChanged.connect(self.changed.emit)
def get_wine_prefixes(self):

View file

@ -10,8 +10,8 @@ from PyQt5.QtWidgets import QVBoxLayout, QHBoxLayout, QLabel, QFileDialog, QLayo
from rare.models.install import MoveGameModel
from rare.models.game import RareGame
from rare.shared import RareCore
from rare.utils.misc import path_size, format_size, icon
from rare.widgets.dialogs import ActionDialog, dialog_title_game
from rare.utils.misc import path_size, format_size, qta_icon
from rare.widgets.dialogs import ActionDialog, game_title
from rare.widgets.elide_label import ElideLabel
from rare.widgets.indicator_edit import PathEdit, IndicatorReasons, IndicatorReasonsCommon
@ -33,8 +33,8 @@ class MoveDialog(ActionDialog):
def __init__(self, rgame: RareGame, parent=None):
super(MoveDialog, self).__init__(parent=parent)
header = self.tr("Move")
self.setWindowTitle(dialog_title_game(header, rgame.app_title))
self.setSubtitle(dialog_title_game(header, rgame.app_title))
self.setWindowTitle(game_title(header, rgame.app_title))
self.setSubtitle(game_title(header, rgame.app_title))
self.rcore = RareCore.instance()
self.core = RareCore.instance().core()
@ -76,7 +76,7 @@ class MoveDialog(ActionDialog):
self.setCentralLayout(layout)
self.accept_button.setText(self.tr("Move"))
self.accept_button.setIcon(icon("mdi.folder-move-outline"))
self.accept_button.setIcon(qta_icon("mdi.folder-move-outline"))
self.action_button.setHidden(True)
@ -135,7 +135,7 @@ class MoveDialog(ActionDialog):
if not os.access(path, os.W_OK) or not os.access(self.rgame.install_path, os.W_OK):
return helper_func(MovePathEditReasons.NO_WRITE_PERM)
if src_path == dst_path or src_path == dst_install_path:
if src_path in {dst_path, dst_install_path}:
return helper_func(MovePathEditReasons.SAME_DIR)
if str(src_path) in str(dst_path):

View file

@ -1,10 +1,10 @@
from PyQt5.QtCore import pyqtSignal
from PyQt5.QtWidgets import QLabel, QVBoxLayout, QLayout, QGroupBox
from PyQt5.QtWidgets import QVBoxLayout, QGroupBox
from rare.models.game import RareGame
from rare.models.install import SelectiveDownloadsModel
from rare.utils.misc import icon
from rare.widgets.dialogs import ButtonDialog, dialog_title_game
from rare.utils.misc import qta_icon
from rare.widgets.dialogs import ButtonDialog, game_title
from rare.widgets.selective_widget import SelectiveWidget
@ -14,8 +14,8 @@ class SelectiveDialog(ButtonDialog):
def __init__(self, rgame: RareGame, parent=None):
super(SelectiveDialog, self).__init__(parent=parent)
header = self.tr("Optional downloads for")
self.setWindowTitle(dialog_title_game(header, rgame.app_title))
self.setSubtitle(dialog_title_game(header, rgame.app_title))
self.setWindowTitle(game_title(header, rgame.app_title))
self.setSubtitle(game_title(header, rgame.app_title))
self.rgame = rgame
self.selective_widget = SelectiveWidget(rgame, rgame.igame.platform, self)
@ -31,7 +31,7 @@ class SelectiveDialog(ButtonDialog):
self.setCentralLayout(layout)
self.accept_button.setText(self.tr("Verify"))
self.accept_button.setIcon(icon("fa.check"))
self.accept_button.setIcon(qta_icon("fa.check"))
self.options: SelectiveDownloadsModel = SelectiveDownloadsModel(rgame.app_name)

View file

@ -6,8 +6,8 @@ from PyQt5.QtWidgets import (
from rare.models.game import RareGame
from rare.models.install import UninstallOptionsModel
from rare.utils.misc import icon
from rare.widgets.dialogs import ButtonDialog, dialog_title_game
from rare.utils.misc import qta_icon
from rare.widgets.dialogs import ButtonDialog, game_title
class UninstallDialog(ButtonDialog):
@ -16,8 +16,8 @@ class UninstallDialog(ButtonDialog):
def __init__(self, rgame: RareGame, options: UninstallOptionsModel, parent=None):
super(UninstallDialog, self).__init__(parent=parent)
header = self.tr("Uninstall")
self.setWindowTitle(dialog_title_game(header, rgame.app_title))
self.setSubtitle(dialog_title_game(header, rgame.app_title))
self.setWindowTitle(game_title(header, rgame.app_title))
self.setSubtitle(game_title(header, rgame.app_title))
self.keep_files = QCheckBox(self.tr("Keep files"))
self.keep_files.setChecked(bool(options.keep_files))
@ -39,7 +39,7 @@ class UninstallDialog(ButtonDialog):
self.setCentralLayout(layout)
self.accept_button.setText(self.tr("Uninstall"))
self.accept_button.setIcon(icon("ri.uninstall-line"))
self.accept_button.setIcon(qta_icon("ri.uninstall-line"))
self.accept_button.setObjectName("UninstallButton")
if rgame.sdl_name is not None:

View file

@ -2,7 +2,7 @@ import os
from logging import getLogger
from PyQt5.QtCore import Qt, QSettings, QTimer, QSize, pyqtSignal, pyqtSlot
from PyQt5.QtGui import QCloseEvent, QCursor, QShowEvent
from PyQt5.QtGui import QCloseEvent, QCursor
from PyQt5.QtWidgets import (
QMainWindow,
QApplication,
@ -16,6 +16,7 @@ from PyQt5.QtWidgets import (
QHBoxLayout,
)
from rare.models.options import options
from rare.components.tabs import MainTabWidget
from rare.components.tray_icon import TrayIcon
from rare.shared import RareCore
@ -93,8 +94,8 @@ class MainWindow(QMainWindow):
# self.status_timer.start()
width, height = 1280, 720
if self.settings.value("save_size", False, bool):
width, height = self.settings.value("window_size", (width, height), tuple)
if self.settings.value(*options.save_size):
width, height = self.settings.value(*options.window_size)
self.resize(width, height)
@ -151,9 +152,9 @@ class MainWindow(QMainWindow):
self._window_launched = True
def hide(self) -> None:
if self.settings.value("save_size", False, bool):
if self.settings.value(*options.save_size):
size = self.size().width(), self.size().height()
self.settings.setValue("window_size", size)
self.settings.setValue(options.window_size.key, size)
super(MainWindow, self).hide()
def toggle(self):
@ -214,7 +215,7 @@ class MainWindow(QMainWindow):
# lk: `accept_close` is set to `True` by the `close()` method, overrides exiting to tray in `closeEvent()`
# lk: ensures exiting instead of hiding when `close()` is called programmatically
if not self.__accept_close:
if self.settings.value("sys_tray", True, bool):
if self.settings.value(*options.sys_tray):
self.hide()
e.ignore()
return

View file

@ -2,13 +2,13 @@ from PyQt5.QtCore import QSize, pyqtSignal, pyqtSlot
from PyQt5.QtWidgets import QMenu, QTabWidget, QWidget, QWidgetAction, QShortcut, QMessageBox
from rare.shared import RareCore, LegendaryCoreSingleton, GlobalSignalsSingleton, ArgumentsSingleton
from rare.utils.misc import icon, ExitCodes
from rare.utils.misc import qta_icon, ExitCodes
from .account import AccountWidget
from .downloads import DownloadsTab
from .games import GamesTab
from .settings import SettingsTab
from .settings.debug import DebugSettings
from .store import Shop
from .store import StoreTab
from .tab_widgets import MainTabBar, TabButtonWidget
@ -18,6 +18,7 @@ class MainTabWidget(QTabWidget):
def __init__(self, parent):
super(MainTabWidget, self).__init__(parent=parent)
self.rcore = RareCore.instance()
self.core = LegendaryCoreSingleton()
self.signals = GlobalSignalsSingleton()
@ -31,14 +32,14 @@ class MainTabWidget(QTabWidget):
self.games_index = self.addTab(self.games_tab, self.tr("Games"))
# Downloads Tab after Games Tab to use populated RareCore games list
if not self.args.offline:
self.downloads_tab = DownloadsTab(self)
self.downloads_index = self.addTab(self.downloads_tab, "")
self.downloads_tab.update_title.connect(self.__on_downloads_update_title)
self.downloads_tab.update_queues_count()
self.setTabEnabled(self.downloads_index, not self.args.offline)
self.downloads_tab = DownloadsTab(self)
self.downloads_index = self.addTab(self.downloads_tab, "")
self.downloads_tab.update_title.connect(self.__on_downloads_update_title)
self.downloads_tab.update_queues_count()
self.setTabEnabled(self.downloads_index, not self.args.offline)
self.store_tab = Shop(self.core)
if not self.args.offline:
self.store_tab = StoreTab(self.core, parent=self)
self.store_index = self.addTab(self.store_tab, self.tr("Store (Beta)"))
self.setTabEnabled(self.store_index, not self.args.offline)
@ -54,22 +55,22 @@ class MainTabWidget(QTabWidget):
self.account_widget.exit_app.connect(self.__on_exit_app)
account_action = QWidgetAction(self)
account_action.setDefaultWidget(self.account_widget)
account_button = TabButtonWidget("mdi.account-circle", "Account", fallback_icon="fa.user")
account_button.setMenu(QMenu())
account_button.menu().addAction(account_action)
account_button = TabButtonWidget(qta_icon("mdi.account-circle", fallback="fa.user"), tooltip="Menu")
account_menu = QMenu(account_button)
account_menu.addAction(account_action)
account_button.setMenu(account_menu)
self.tab_bar.setTabButton(
button_index, MainTabBar.RightSide, account_button
)
self.settings_tab = SettingsTab(self)
self.settings_index = self.addTab(self.settings_tab, icon("fa.gear"), "")
self.settings_index = self.addTab(self.settings_tab, qta_icon("fa.gear"), "")
self.settings_tab.about.update_available_ready.connect(
lambda: self.tab_bar.setTabText(self.settings_index, "(!)")
)
# Open game list on click on Games tab button
self.tabBarClicked.connect(self.mouse_clicked)
self.setIconSize(QSize(24, 24))
# shortcuts
QShortcut("Alt+1", self).activated.connect(lambda: self.setCurrentIndex(self.games_index))

View file

@ -4,7 +4,7 @@ from PyQt5.QtCore import pyqtSignal, pyqtSlot
from PyQt5.QtWidgets import QWidget, QVBoxLayout, QLabel, QPushButton
from rare.shared import LegendaryCoreSingleton, GlobalSignalsSingleton
from rare.utils.misc import icon, ExitCodes
from rare.utils.misc import qta_icon, ExitCodes
class AccountWidget(QWidget):
@ -20,7 +20,7 @@ class AccountWidget(QWidget):
if not username:
username = "Offline"
self.open_browser = QPushButton(icon("fa.external-link"), self.tr("Account settings"))
self.open_browser = QPushButton(qta_icon("fa.external-link"), self.tr("Account settings"))
self.open_browser.clicked.connect(
lambda: webbrowser.open(
"https://www.epicgames.com/account/personal?productName=epicgames"

View file

@ -15,6 +15,7 @@ from rare.components.dialogs.uninstall_dialog import UninstallDialog
from rare.lgndr.models.downloading import UIUpdate
from rare.models.game import RareGame
from rare.models.install import InstallOptionsModel, InstallQueueItemModel, UninstallOptionsModel
from rare.models.options import options
from rare.shared import RareCore
from rare.shared.workers.install_info import InstallInfoWorker
from rare.shared.workers.uninstall import UninstallWorker
@ -60,6 +61,8 @@ class DownloadsTab(QWidget):
queue_contents = QWidget(self.queue_scrollarea)
queue_contents.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed)
self.queue_scrollarea.setWidget(queue_contents)
self.queue_scrollarea.widget().setAutoFillBackground(False)
self.queue_scrollarea.viewport().setAutoFillBackground(False)
queue_contents_layout = QVBoxLayout(queue_contents)
queue_contents_layout.setContentsMargins(0, 0, 3, 0)
@ -105,9 +108,13 @@ class DownloadsTab(QWidget):
def __add_update(self, update: Union[str, RareGame]):
if isinstance(update, str):
update = self.rcore.get_game(update)
if QSettings().value(
f"{update.app_name}/auto_update", False, bool
) or QSettings().value("auto_update", False, bool):
auto_update = QSettings(self).value(
f"{update.app_name}/{options.auto_update.key}",
QSettings(self).value(*options.auto_update),
options.auto_update.dtype
)
if auto_update:
self.__get_install_options(
InstallOptionsModel(app_name=update.app_name, update=True, silent=True)
)

View file

@ -52,16 +52,18 @@ class DownloadWidget(ImageWidget):
# lk: trade some possible delay and start-up time
# lk: for faster rendering. Gradients are expensive
# lk: so pre-generate the image
super(DownloadWidget, self).setPixmap(self.prepare_pixmap(pixmap))
if not pixmap.isNull():
pixmap = self.prepare_pixmap(pixmap)
super(DownloadWidget, self).setPixmap(pixmap)
def paint_image_empty(self, painter: QPainter, a0: QPaintEvent) -> None:
# when pixmap object is not available yet, show a gray rectangle
painter.setOpacity(0.5 * self._opacity)
painter.fillRect(a0.rect(), self.palette().color(QPalette.Background))
painter.fillRect(a0.rect(), self.palette().color(QPalette.Window))
def paint_image_cover(self, painter: QPainter, a0: QPaintEvent) -> None:
painter.setOpacity(self._opacity)
color = self.palette().color(QPalette.Background).darker(75)
color = self.palette().color(QPalette.Window).darker(75)
painter.fillRect(self.rect(), color)
brush = QBrush(self._pixmap)
brush.setTransform(self._transform)

View file

@ -55,7 +55,7 @@ class DlThread(QThread):
if result.code == DlResultCode.FINISHED:
self.rgame.set_installed(True)
self.rgame.state = RareGame.State.IDLE
self.rgame.signals.progress.finish.emit(not result.code == DlResultCode.FINISHED)
self.rgame.signals.progress.finish.emit(result.code != DlResultCode.FINISHED)
self.result.emit(result)
def __status_callback(self, status: UIUpdate):
@ -125,15 +125,14 @@ class DlThread(QThread):
dlcs = self.core.get_dlc_for_game(self.item.download.igame.app_name)
if dlcs and not self.item.options.skip_dlcs:
result.dlcs = []
for dlc in dlcs:
result.dlcs.append(
{
"app_name": dlc.app_name,
"app_title": dlc.app_title,
"app_version": dlc.app_version(self.item.options.platform),
}
)
result.dlcs.extend(
{
"app_name": dlc.app_name,
"app_title": dlc.app_title,
"app_version": dlc.app_version(self.item.options.platform),
}
for dlc in dlcs
)
if (
self.item.download.game.supports_cloud_saves
or self.item.download.game.supports_mac_cloud_saves

View file

@ -12,10 +12,9 @@ from rare.shared import (
ImageManagerSingleton,
)
from rare.shared import RareCore
from rare.widgets.library_layout import LibraryLayout
from rare.widgets.sliding_stack import SlidingStackedWidget
from rare.models.options import options
from .game_info import GameInfoTabs
from .game_widgets import LibraryWidgetController
from .game_widgets import LibraryWidgetController, LibraryFilter, LibraryOrder, LibraryView
from .game_widgets.icon_game_widget import IconGameWidget
from .game_widgets.list_game_widget import ListGameWidget
from .head_bar import GameListHeadBar
@ -53,51 +52,22 @@ class GamesTab(QStackedWidget):
self.integrations_page.back_clicked.connect(lambda: self.setCurrentWidget(self.games_page))
self.addWidget(self.integrations_page)
self.view_stack = SlidingStackedWidget(self.games_page)
self.view_stack.setFrameStyle(QFrame.NoFrame)
self.view_scroll = QScrollArea(self.games_page)
self.view_scroll.setWidgetResizable(True)
self.view_scroll.setFrameShape(QFrame.StyledPanel)
self.view_scroll.horizontalScrollBar().setDisabled(True)
self.icon_view_scroll = QScrollArea(self.view_stack)
self.icon_view_scroll.setWidgetResizable(True)
self.icon_view_scroll.setFrameShape(QFrame.StyledPanel)
self.icon_view_scroll.horizontalScrollBar().setDisabled(True)
library_view = LibraryView(self.settings.value(*options.library_view))
self.library_controller = LibraryWidgetController(library_view, self.view_scroll)
games_page_layout.addWidget(self.view_scroll)
self.list_view_scroll = QScrollArea(self.view_stack)
self.list_view_scroll.setWidgetResizable(True)
self.list_view_scroll.setFrameShape(QFrame.StyledPanel)
self.list_view_scroll.horizontalScrollBar().setDisabled(True)
self.icon_view = QWidget(self.icon_view_scroll)
icon_view_layout = LibraryLayout(self.icon_view)
icon_view_layout.setSpacing(9)
icon_view_layout.setContentsMargins(0, 13, 0, 13)
icon_view_layout.setAlignment(Qt.AlignTop)
self.list_view = QWidget(self.list_view_scroll)
list_view_layout = QVBoxLayout(self.list_view)
list_view_layout.setContentsMargins(3, 3, 9, 3)
list_view_layout.setAlignment(Qt.AlignTop)
self.library_controller = LibraryWidgetController(self.icon_view, self.list_view, self)
self.icon_view_scroll.setWidget(self.icon_view)
self.list_view_scroll.setWidget(self.list_view)
self.view_stack.addWidget(self.icon_view_scroll)
self.view_stack.addWidget(self.list_view_scroll)
games_page_layout.addWidget(self.view_stack)
if not self.settings.value("icon_view", True, bool):
self.view_stack.setCurrentWidget(self.list_view_scroll)
self.head_bar.view.list()
else:
self.view_stack.setCurrentWidget(self.icon_view_scroll)
self.head_bar.search_bar.textChanged.connect(lambda x: self.filter_games("", x))
self.head_bar.search_bar.textChanged.connect(self.search_games)
self.head_bar.search_bar.textChanged.connect(self.scroll_to_top)
self.head_bar.filterChanged.connect(self.filter_games)
self.head_bar.filterChanged.connect(self.scroll_to_top)
self.head_bar.refresh_list.clicked.connect(self.library_controller.update_list)
self.head_bar.view.toggled.connect(self.toggle_view)
self.active_filter: str = self.head_bar.filter.currentData(Qt.UserRole)
self.head_bar.orderChanged.connect(self.order_games)
self.head_bar.orderChanged.connect(self.scroll_to_top)
self.head_bar.refresh_list.clicked.connect(self.library_controller.update_game_views)
# signals
self.signals.game.installed.connect(self.update_count_games_label)
@ -114,11 +84,8 @@ class GamesTab(QStackedWidget):
@pyqtSlot()
def scroll_to_top(self):
self.icon_view_scroll.verticalScrollBar().setSliderPosition(
self.icon_view_scroll.verticalScrollBar().minimum()
)
self.list_view_scroll.verticalScrollBar().setSliderPosition(
self.list_view_scroll.verticalScrollBar().minimum()
self.view_scroll.verticalScrollBar().setSliderPosition(
self.view_scroll.verticalScrollBar().minimum()
)
@pyqtSlot()
@ -139,8 +106,8 @@ class GamesTab(QStackedWidget):
@pyqtSlot(RareGame)
def show_game_info(self, rgame):
self.setCurrentWidget(self.game_info_page)
self.game_info_page.update_game(rgame)
self.setCurrentWidget(self.game_info_page)
@pyqtSlot()
def update_count_games_label(self):
@ -151,42 +118,38 @@ class GamesTab(QStackedWidget):
def setup_game_list(self):
for rgame in self.rcore.games:
icon_widget, list_widget = self.add_library_widget(rgame)
if not icon_widget or not list_widget:
widget = self.add_library_widget(rgame)
if not widget:
logger.warning("Excluding %s from the game list", rgame.app_title)
continue
self.icon_view.layout().addWidget(icon_widget)
self.list_view.layout().addWidget(list_widget)
self.filter_games(self.active_filter)
self.filter_games(self.head_bar.current_filter())
self.update_count_games_label()
def add_library_widget(self, rgame: RareGame):
try:
icon_widget, list_widget = self.library_controller.add_game(rgame)
widget = self.library_controller.add_game(rgame)
except Exception as e:
logger.error("Could not add widget for %s to library: %s", rgame.app_name, e)
return None, None
icon_widget.show_info.connect(self.show_game_info)
list_widget.show_info.connect(self.show_game_info)
return icon_widget, list_widget
return None
widget.show_info.connect(self.show_game_info)
return widget
@pyqtSlot(str)
@pyqtSlot(str, str)
def filter_games(self, filter_name="all", search_text: str = ""):
def search_games(self, search_text: str = ""):
self.filter_games(self.head_bar.current_filter(), search_text)
@pyqtSlot(object)
@pyqtSlot(object, str)
def filter_games(self, library_filter: LibraryFilter = LibraryFilter.ALL, search_text: str = ""):
if not search_text and (t := self.head_bar.search_bar.text()):
search_text = t
if filter_name:
self.active_filter = filter_name
if not filter_name and (t := self.active_filter):
filter_name = t
self.library_controller.filter_game_views(library_filter, search_text.lower())
self.library_controller.filter_list(filter_name, search_text.lower())
@pyqtSlot(object)
@pyqtSlot(object, str)
def order_games(self, library_order: LibraryOrder = LibraryFilter.ALL, search_text: str = ""):
if not search_text and (t := self.head_bar.search_bar.text()):
search_text = t
def toggle_view(self):
self.settings.setValue("icon_view", not self.head_bar.view.isChecked())
if not self.head_bar.view.isChecked():
self.view_stack.slideInWidget(self.icon_view_scroll)
else:
self.view_stack.slideInWidget(self.list_view_scroll)
self.library_controller.order_game_views(library_order, search_text.lower())

View file

@ -1,6 +1,6 @@
from typing import Optional
from PyQt5.QtCore import Qt, pyqtSignal, pyqtSlot
from PyQt5.QtCore import Qt, pyqtSignal
from PyQt5.QtGui import QKeyEvent
from PyQt5.QtWidgets import QTreeView
@ -8,9 +8,9 @@ from rare.models.game import RareGame
from rare.shared import LegendaryCoreSingleton, GlobalSignalsSingleton, ArgumentsSingleton
from rare.utils.json_formatter import QJsonModel
from rare.widgets.side_tab import SideTabWidget, SideTabContents
from .game_dlc import GameDlc
from .game_info import GameInfo
from .game_settings import GameSettings
from .dlcs import GameDlcs
from .details import GameDetails
from .settings import GameSettings
from .cloud_saves import CloudSaves
@ -24,9 +24,9 @@ class GameInfoTabs(SideTabWidget):
self.signals = GlobalSignalsSingleton()
self.args = ArgumentsSingleton()
self.info_tab = GameInfo(self)
self.info_tab.import_clicked.connect(self.import_clicked)
self.info_index = self.addTab(self.info_tab, self.tr("Information"))
self.details_tab = GameDetails(self)
self.details_tab.import_clicked.connect(self.import_clicked)
self.details_index = self.addTab(self.details_tab, self.tr("Information"))
self.settings_tab = GameSettings(self)
self.settings_index = self.addTab(self.settings_tab, self.tr("Settings"))
@ -34,8 +34,8 @@ class GameInfoTabs(SideTabWidget):
self.cloud_saves_tab = CloudSaves(self)
self.cloud_saves_index = self.addTab(self.cloud_saves_tab, self.tr("Cloud Saves"))
self.dlc_tab = GameDlc(self)
self.dlc_index = self.addTab(self.dlc_tab, self.tr("Downloadable Content"))
self.dlcs_tab = GameDlcs(self)
self.dlcs_index = self.addTab(self.dlcs_tab, self.tr("Downloadable Content"))
# FIXME: Hiding didn't work, so don't add these tabs in normal mode. Fix this properly later
if self.args.debug:
@ -43,17 +43,19 @@ class GameInfoTabs(SideTabWidget):
self.game_meta_index = self.addTab(self.game_meta_view, self.tr("Game Metadata"))
self.igame_meta_view = GameMetadataView(self)
self.igame_meta_index = self.addTab(self.igame_meta_view, self.tr("InstalledGame Metadata"))
self.rgame_meta_view = GameMetadataView(self)
self.rgame_meta_index = self.addTab(self.rgame_meta_view, self.tr("RareGame Metadata"))
self.setCurrentIndex(self.info_index)
self.setCurrentIndex(self.details_index)
def update_game(self, rgame: RareGame):
self.info_tab.update_game(rgame)
self.details_tab.update_game(rgame)
self.settings_tab.load_settings(rgame)
self.settings_tab.setEnabled(rgame.is_installed or rgame.is_origin)
self.dlc_tab.update_dlcs(rgame)
self.dlc_tab.setEnabled(rgame.is_installed and bool(rgame.owned_dlcs))
self.dlcs_tab.update_dlcs(rgame)
self.dlcs_tab.setEnabled(rgame.is_installed and bool(rgame.owned_dlcs))
self.cloud_saves_tab.update_game(rgame)
# self.cloud_saves_tab.setEnabled(rgame.game.supports_cloud_saves or rgame.game.supports_mac_cloud_saves)
@ -61,11 +63,12 @@ class GameInfoTabs(SideTabWidget):
if self.args.debug:
self.game_meta_view.update_game(rgame, rgame.game)
self.igame_meta_view.update_game(rgame, rgame.igame)
self.rgame_meta_view.update_game(rgame, rgame.metadata)
self.setCurrentIndex(self.info_index)
self.setCurrentIndex(self.details_index)
def keyPressEvent(self, e: QKeyEvent):
if e.key() == Qt.Key_Escape:
def keyPressEvent(self, a0: QKeyEvent):
if a0.key() == Qt.Key_Escape:
self.back_clicked.emit()
@ -75,6 +78,7 @@ class GameMetadataView(QTreeView, SideTabContents):
self.implements_scrollarea = True
self.setColumnWidth(0, 300)
self.setWordWrap(True)
self.setEditTriggers(QTreeView.NoEditTriggers)
self.model = QJsonModel()
self.setModel(self.model)
self.rgame: Optional[RareGame] = None

View file

@ -3,7 +3,7 @@ import platform
from logging import getLogger
from typing import Tuple
from PyQt5.QtCore import QThreadPool, QSettings
from PyQt5.QtCore import QThreadPool, QSettings, pyqtSlot
from PyQt5.QtWidgets import (
QWidget,
QFileDialog,
@ -19,15 +19,16 @@ from legendary.models.game import SaveGameStatus
from rare.models.game import RareGame
from rare.shared import RareCore
from rare.shared.workers.wine_resolver import WineResolver
from rare.shared.workers.wine_resolver import WineSavePathResolver
from rare.ui.components.tabs.games.game_info.cloud_settings_widget import Ui_CloudSettingsWidget
from rare.ui.components.tabs.games.game_info.cloud_sync_widget import Ui_CloudSyncWidget
from rare.utils.misc import icon
from rare.utils.misc import qta_icon
from rare.utils.metrics import timelogger
from rare.widgets.indicator_edit import PathEdit, IndicatorReasonsCommon
from rare.widgets.loading_widget import LoadingWidget
from rare.widgets.side_tab import SideTabContents
logger = getLogger("CloudWidget")
logger = getLogger("CloudSaves")
class CloudSaves(QWidget, SideTabContents):
@ -44,8 +45,8 @@ class CloudSaves(QWidget, SideTabContents):
self.core = RareCore.instance().core()
self.settings = QSettings()
self.sync_ui.icon_local.setPixmap(icon("mdi.harddisk", "fa.desktop").pixmap(128, 128))
self.sync_ui.icon_remote.setPixmap(icon("mdi.cloud-outline", "ei.cloud").pixmap(128, 128))
self.sync_ui.icon_local.setPixmap(qta_icon("mdi.harddisk", "fa.desktop").pixmap(128, 128))
self.sync_ui.icon_remote.setPixmap(qta_icon("mdi.cloud-outline", "ei.cloud").pixmap(128, 128))
self.sync_ui.upload_button.clicked.connect(self.upload)
self.sync_ui.download_button.clicked.connect(self.download)
@ -72,7 +73,7 @@ class CloudSaves(QWidget, SideTabContents):
self.cloud_save_path_edit
)
self.compute_save_path_button = QPushButton(icon("fa.magic"), self.tr("Calculate path"))
self.compute_save_path_button = QPushButton(qta_icon("fa.magic"), self.tr("Calculate path"))
self.compute_save_path_button.setSizePolicy(QSizePolicy.Maximum, QSizePolicy.Fixed)
self.compute_save_path_button.clicked.connect(self.compute_save_path)
self.cloud_ui.main_layout.addRow(None, self.compute_save_path_button)
@ -114,30 +115,32 @@ class CloudSaves(QWidget, SideTabContents):
def compute_save_path(self):
if self.rgame.is_installed and self.rgame.game.supports_cloud_saves:
try:
new_path = self.core.get_save_path(self.rgame.app_name)
with timelogger(logger, "Detecting save path"):
new_path = self.core.get_save_path(self.rgame.app_name)
if platform.system() != "Windows" and not os.path.exists(new_path):
raise ValueError(f'Path "{new_path}" does not exist.')
except Exception as e:
logger.warning(str(e))
resolver = WineResolver(self.core, self.rgame.raw_save_path, self.rgame.app_name)
if not resolver.wine_env.get("WINEPREFIX"):
del resolver
self.cloud_save_path_edit.setText("")
QMessageBox.warning(self, "Warning", "No wine prefix selected. Please set it in settings")
return
resolver = WineSavePathResolver(self.core, self.rgame)
# if not resolver.environ.get("WINEPREFIX"):
# del resolver
# self.cloud_save_path_edit.setText("")
# QMessageBox.warning(self, "Warning", "No wine prefix selected. Please set it in settings")
# return
self.cloud_save_path_edit.setText(self.tr("Loading..."))
self.cloud_save_path_edit.setDisabled(True)
self.compute_save_path_button.setDisabled(True)
app_name = self.rgame.app_name
resolver.signals.result_ready.connect(lambda x: self.wine_resolver_finished(x, app_name))
resolver.signals.result_ready.connect(self.__on_wine_resolver_result)
QThreadPool.globalInstance().start(resolver)
return
else:
self.cloud_save_path_edit.setText(new_path)
def wine_resolver_finished(self, path, app_name):
logger.info(f"Wine resolver finished for {app_name}. Computed save path: {path}")
@pyqtSlot(str, str)
def __on_wine_resolver_result(self, path, app_name):
logger.info("Wine resolver finished for %s", app_name)
logger.info("Computed save path: %s", path)
if app_name == self.rgame.app_name:
self.cloud_save_path_edit.setDisabled(False)
self.compute_save_path_button.setDisabled(False)
@ -158,8 +161,6 @@ class CloudSaves(QWidget, SideTabContents):
self.cloud_save_path_edit.setText("")
return
self.cloud_save_path_edit.setText(path)
elif path:
self.rcore.get_game(app_name).save_path = path
def __update_widget(self):
supports_saves = self.rgame.igame is not None and (

View file

@ -19,8 +19,8 @@ from rare.components.dialogs.selective_dialog import SelectiveDialog
from rare.models.game import RareGame
from rare.shared import RareCore
from rare.shared.workers import VerifyWorker, MoveWorker
from rare.ui.components.tabs.games.game_info.game_info import Ui_GameInfo
from rare.utils.misc import format_size, icon
from rare.ui.components.tabs.games.game_info.details import Ui_GameDetails
from rare.utils.misc import format_size, qta_icon, style_hyperlink
from rare.widgets.image_widget import ImageWidget, ImageSize
from rare.widgets.side_tab import SideTabContents
from rare.components.dialogs.move_dialog import MoveDialog, is_game_dir
@ -28,27 +28,27 @@ from rare.components.dialogs.move_dialog import MoveDialog, is_game_dir
logger = getLogger("GameInfo")
class GameInfo(QWidget, SideTabContents):
class GameDetails(QWidget, SideTabContents):
# str: app_name
import_clicked = pyqtSignal(str)
def __init__(self, parent=None):
super(GameInfo, self).__init__(parent=parent)
self.ui = Ui_GameInfo()
super(GameDetails, self).__init__(parent=parent)
self.ui = Ui_GameDetails()
self.ui.setupUi(self)
# lk: set object names for CSS properties
self.ui.install_button.setObjectName("InstallButton")
self.ui.modify_button.setObjectName("InstallButton")
self.ui.uninstall_button.setObjectName("UninstallButton")
self.ui.install_button.setIcon(icon("ri.install-line"))
self.ui.import_button.setIcon(icon("mdi.application-import"))
self.ui.install_button.setIcon(qta_icon("ri.install-line"))
self.ui.import_button.setIcon(qta_icon("mdi.application-import"))
self.ui.modify_button.setIcon(icon("fa.gear"))
self.ui.verify_button.setIcon(icon("fa.check"))
self.ui.repair_button.setIcon(icon("fa.wrench"))
self.ui.move_button.setIcon(icon("mdi.folder-move-outline"))
self.ui.uninstall_button.setIcon(icon("ri.uninstall-line"))
self.ui.modify_button.setIcon(qta_icon("fa.gear"))
self.ui.verify_button.setIcon(qta_icon("fa.check"))
self.ui.repair_button.setIcon(qta_icon("fa.wrench"))
self.ui.move_button.setIcon(qta_icon("mdi.folder-move-outline"))
self.ui.uninstall_button.setIcon(qta_icon("ri.uninstall-line"))
self.rcore = RareCore.instance()
self.core = RareCore.instance().core()
@ -270,8 +270,7 @@ class GameInfo(QWidget, SideTabContents):
@pyqtSlot()
def __update_widget(self):
""" React to state updates from RareGame """
# self.image.setPixmap(self.image_manager.get_pixmap(self.rgame.app_name, True))
self.image.setPixmap(self.rgame.pixmap)
self.image.setPixmap(self.rgame.get_pixmap(True))
self.ui.lbl_version.setDisabled(self.rgame.is_non_asset)
self.ui.version.setDisabled(self.rgame.is_non_asset)
@ -303,7 +302,12 @@ class GameInfo(QWidget, SideTabContents):
self.ui.grade.setDisabled(
self.rgame.is_unreal or platform.system() == "Windows"
)
self.ui.grade.setText(self.steam_grade_ratings[self.rgame.steam_grade()])
self.ui.grade.setText(
style_hyperlink(
f"https://www.protondb.com/app/{self.rgame.steam_appid}",
self.steam_grade_ratings[self.rgame.steam_grade()]
)
)
self.ui.install_button.setEnabled(
(not self.rgame.is_installed or self.rgame.is_non_asset) and self.rgame.is_idle

View file

@ -6,11 +6,11 @@ from PyQt5.QtWidgets import QFrame, QMessageBox, QToolBox
from rare.models.game import RareGame
from rare.shared import LegendaryCoreSingleton, GlobalSignalsSingleton
from rare.ui.components.tabs.games.game_info.game_dlc import Ui_GameDlc
from rare.ui.components.tabs.games.game_info.game_dlc_widget import Ui_GameDlcWidget
from rare.ui.components.tabs.games.game_info.dlcs import Ui_GameDlcs
from rare.ui.components.tabs.games.game_info.dlc_widget import Ui_GameDlcWidget
from rare.widgets.image_widget import ImageWidget, ImageSize
from rare.widgets.side_tab import SideTabContents
from rare.utils.misc import widget_object_name, icon
from rare.utils.misc import widget_object_name, qta_icon
class GameDlcWidget(QFrame):
@ -57,7 +57,7 @@ class InstalledGameDlcWidget(GameDlcWidget):
self.ui.action_button.setObjectName("UninstallButton")
self.ui.action_button.clicked.connect(self.uninstall_dlc)
self.ui.action_button.setText(self.tr("Uninstall DLC"))
self.ui.action_button.setIcon(icon("ri.uninstall-line"))
self.ui.action_button.setIcon(qta_icon("ri.uninstall-line"))
# lk: don't reference `self.rdlc` here because the object has been deleted
rdlc.signals.game.uninstalled.connect(self.__uninstalled)
@ -78,7 +78,7 @@ class AvailableGameDlcWidget(GameDlcWidget):
self.ui.action_button.setObjectName("InstallButton")
self.ui.action_button.clicked.connect(self.install_dlc)
self.ui.action_button.setText(self.tr("Install DLC"))
self.ui.action_button.setIcon(icon("ri.install-line"))
self.ui.action_button.setIcon(qta_icon("ri.install-line"))
# lk: don't reference `self.rdlc` here because the object has been deleted
rdlc.signals.game.installed.connect(self.__installed)
@ -98,12 +98,12 @@ class AvailableGameDlcWidget(GameDlcWidget):
self.rdlc.install()
class GameDlc(QToolBox, SideTabContents):
class GameDlcs(QToolBox, SideTabContents):
def __init__(self, parent=None):
super(GameDlc, self).__init__(parent=parent)
super(GameDlcs, self).__init__(parent=parent)
self.implements_scrollarea = True
self.ui = Ui_GameDlc()
self.ui = Ui_GameDlcs()
self.ui.setupUi(self)
self.core = LegendaryCoreSingleton()
self.signals = GlobalSignalsSingleton()

View file

@ -1,139 +0,0 @@
import os.path
import platform
from logging import getLogger
from typing import Tuple
from PyQt5.QtCore import Qt
from PyQt5.QtWidgets import QLabel, QFileDialog
from legendary.models.game import Game, InstalledGame
from rare.components.tabs.settings import DefaultGameSettings
from rare.components.tabs.settings.widgets.pre_launch import PreLaunchSettings
from rare.models.game import RareGame
from rare.utils import config_helper
from rare.widgets.side_tab import SideTabContents
from rare.widgets.indicator_edit import PathEdit, IndicatorReasonsCommon
logger = getLogger("GameSettings")
class GameSettings(DefaultGameSettings, SideTabContents):
def __init__(self, parent=None):
super(GameSettings, self).__init__(False, parent=parent)
self.pre_launch_settings = PreLaunchSettings()
self.ui.launch_settings_group.layout().addRow(
QLabel(self.tr("Pre-launch command")), self.pre_launch_settings
)
self.ui.skip_update.currentIndexChanged.connect(
lambda x: self.update_combobox("skip_update_check", x)
)
self.ui.offline.currentIndexChanged.connect(
lambda x: self.update_combobox("offline", x)
)
self.ui.launch_params.textChanged.connect(
lambda x: self.line_edit_save_callback("start_params", x)
)
self.override_exe_edit = PathEdit(
file_mode=QFileDialog.ExistingFile,
name_filters=["*.exe", "*.app"],
placeholder=self.tr("Relative path to launch executable"),
edit_func=self.override_exe_edit_callback,
save_func=self.override_exe_save_callback,
parent=self
)
self.ui.launch_settings_layout.insertRow(
self.ui.launch_settings_layout.getWidgetPosition(self.ui.launch_params)[0] + 1,
QLabel(self.tr("Override executable"), self), self.override_exe_edit
)
self.ui.game_settings_layout.setAlignment(Qt.AlignTop)
self.game: Game = None
self.igame: InstalledGame = None
def override_exe_edit_callback(self, path: str) -> Tuple[bool, str, int]:
if not path or self.igame is None:
return True, path, IndicatorReasonsCommon.VALID
if not os.path.isabs(path):
path = os.path.join(self.igame.install_path, path)
if self.igame.install_path not in path:
return False, self.igame.install_path, IndicatorReasonsCommon.WRONG_PATH
if not os.path.exists(path):
return False, path, IndicatorReasonsCommon.WRONG_PATH
if not path.endswith(".exe") and not path.endswith(".app"):
return False, path, IndicatorReasonsCommon.WRONG_PATH
path = os.path.relpath(path, self.igame.install_path)
return True, path, IndicatorReasonsCommon.VALID
def override_exe_save_callback(self, path: str):
self.line_edit_save_callback("override_exe", path)
def line_edit_save_callback(self, option, value) -> None:
if value:
config_helper.add_option(self.game.app_name, option, value)
else:
config_helper.remove_option(self.game.app_name, option)
config_helper.save_config()
def update_combobox(self, option, index):
if self.change:
# remove section
if index:
if index == 1:
config_helper.add_option(self.game.app_name, option, "true")
if index == 2:
config_helper.add_option(self.game.app_name, option, "false")
else:
config_helper.remove_option(self.game.app_name, option)
config_helper.save_config()
def load_settings(self, rgame: RareGame):
self.change = False
# FIXME: Use RareGame for the rest of the code
app_name = rgame.app_name
super(GameSettings, self).load_settings(app_name)
self.game = rgame.game
self.igame = rgame.igame
if self.igame:
if self.igame.can_run_offline:
offline = self.core.lgd.config.get(self.game.app_name, "offline", fallback="unset")
if offline == "true":
self.ui.offline.setCurrentIndex(1)
elif offline == "false":
self.ui.offline.setCurrentIndex(2)
else:
self.ui.offline.setCurrentIndex(0)
self.ui.offline.setEnabled(True)
else:
self.ui.offline.setEnabled(False)
self.override_exe_edit.set_root(self.igame.install_path)
else:
self.ui.offline.setEnabled(False)
self.override_exe_edit.set_root("")
skip_update = self.core.lgd.config.get(self.game.app_name, "skip_update_check", fallback="unset")
if skip_update == "true":
self.ui.skip_update.setCurrentIndex(1)
elif skip_update == "false":
self.ui.skip_update.setCurrentIndex(2)
else:
self.ui.skip_update.setCurrentIndex(0)
self.set_title.emit(self.game.app_title)
if platform.system() != "Windows":
if self.igame and self.igame.platform == "Mac":
self.ui.linux_settings_widget.setVisible(False)
else:
self.ui.linux_settings_widget.setVisible(True)
self.ui.launch_params.setText(self.core.lgd.config.get(self.game.app_name, "start_params", fallback=""))
self.override_exe_edit.setText(
self.core.lgd.config.get(self.game.app_name, "override_exe", fallback="")
)
self.pre_launch_settings.load_settings(app_name)
self.change = True

View file

@ -0,0 +1,197 @@
import os.path
import platform as pf
from logging import getLogger
from typing import Tuple
from PyQt5.QtCore import Qt, pyqtSlot
from PyQt5.QtGui import QShowEvent
from PyQt5.QtWidgets import QFileDialog, QComboBox, QLineEdit
from legendary.models.game import Game, InstalledGame
from rare.components.tabs.settings.widgets.env_vars import EnvVars
from rare.components.tabs.settings.widgets.game import GameSettingsBase
from rare.components.tabs.settings.widgets.launch import LaunchSettingsBase
from rare.components.tabs.settings.widgets.overlay import DxvkSettings
from rare.components.tabs.settings.widgets.wrappers import WrapperSettings
from rare.models.game import RareGame
from rare.utils import config_helper as config
from rare.widgets.indicator_edit import PathEdit, IndicatorReasonsCommon
if pf.system() != "Windows":
from rare.components.tabs.settings.widgets.wine import WineSettings
if pf.system() in {"Linux", "FreeBSD"}:
from rare.components.tabs.settings.widgets.proton import ProtonSettings
from rare.components.tabs.settings.widgets.overlay import MangoHudSettings
logger = getLogger("GameSettings")
class GameWrapperSettings(WrapperSettings):
def __init__(self, parent=None):
super().__init__(parent=parent)
def load_settings(self, app_name: str):
self.app_name = app_name
class GameLaunchSettings(LaunchSettingsBase):
def __init__(self, parent=None):
super(GameLaunchSettings, self).__init__(GameWrapperSettings, parent=parent)
self.game: Game = None
self.igame: InstalledGame = None
self.skip_update_combo = QComboBox(self)
self.skip_update_combo.addItem(self.tr("Default"), None)
self.skip_update_combo.addItem(self.tr("No"), "false")
self.skip_update_combo.addItem(self.tr("Yes"), "true")
self.skip_update_combo.currentIndexChanged.connect(self.__skip_update_changed)
self.offline_combo = QComboBox(self)
self.offline_combo.addItem(self.tr("Default"), None)
self.offline_combo.addItem(self.tr("No"), "false")
self.offline_combo.addItem(self.tr("Yes"), "true")
self.offline_combo.currentIndexChanged.connect(self.__offline_changed)
self.override_exe_edit = PathEdit(
file_mode=QFileDialog.ExistingFile,
name_filters=["*.exe", "*.app"],
placeholder=self.tr("Relative path to the replacement executable"),
edit_func=self.__override_exe_edit_callback,
save_func=self.__override_exe_save_callback,
parent=self
)
self.launch_params_edit = QLineEdit(self)
self.launch_params_edit.setPlaceholderText(self.tr("Game specific command line arguments"))
self.launch_params_edit.textChanged.connect(self.__launch_params_changed)
self.main_layout.insertRow(0, self.tr("Skip update check"), self.skip_update_combo)
self.main_layout.insertRow(1, self.tr("Offline mode"), self.offline_combo)
self.main_layout.insertRow(2, self.tr("Launch parameters"), self.launch_params_edit)
self.main_layout.insertRow(3, self.tr("Override executable"), self.override_exe_edit)
def showEvent(self, a0: QShowEvent):
if a0.spontaneous():
return super().showEvent(a0)
skip_update = config.get_option(self.app_name, "skip_update_check", fallback=None)
self.skip_update_combo.setCurrentIndex(self.offline_combo.findData(skip_update, Qt.UserRole))
offline = config.get_option(self.app_name, "offline", fallback=None)
self.offline_combo.setCurrentIndex(self.offline_combo.findData(offline, Qt.UserRole))
if self.igame:
self.offline_combo.setEnabled(self.igame.can_run_offline)
self.override_exe_edit.setRootPath(self.igame.install_path)
else:
self.offline_combo.setEnabled(False)
self.override_exe_edit.setRootPath(os.path.expanduser("~/"))
launch_params = config.get_option(self.app_name, "start_params", "")
self.launch_params_edit.setText(launch_params)
override_exe = config.get_option(self.app_name, "override_exe", fallback="")
self.override_exe_edit.setText(override_exe)
return super().showEvent(a0)
@pyqtSlot(int)
def __skip_update_changed(self, index):
data = self.skip_update_combo.itemData(index, Qt.UserRole)
config.save_option(self.app_name, "skip_update_check", data)
def __override_exe_edit_callback(self, path: str) -> Tuple[bool, str, int]:
if not path or self.igame is None:
return True, path, IndicatorReasonsCommon.VALID
if not os.path.isabs(path):
path = os.path.join(self.igame.install_path, path)
# lk: Compare paths through python's commonpath because in windows we
# cannot compare as strings
# antonia disapproves of this
if os.path.commonpath([self.igame.install_path, path]) != self.igame.install_path:
return False, self.igame.install_path, IndicatorReasonsCommon.WRONG_PATH
if not os.path.exists(path):
return False, path, IndicatorReasonsCommon.WRONG_PATH
if not path.endswith(".exe") and not path.endswith(".app"):
return False, path, IndicatorReasonsCommon.WRONG_PATH
path = os.path.relpath(path, self.igame.install_path)
return True, path, IndicatorReasonsCommon.VALID
def __override_exe_save_callback(self, path: str):
config.save_option(self.app_name, "override_exe", path)
@pyqtSlot(int)
def __offline_changed(self, index):
data = self.skip_update_combo.itemData(index, Qt.UserRole)
config.save_option(self.app_name, "offline", data)
def __launch_params_changed(self, value) -> None:
config.save_option(self.app_name, "start_params", value)
def load_settings(self, rgame: RareGame):
self.game = rgame.game
self.igame = rgame.igame
self.app_name = rgame.app_name
self.wrappers_widget.load_settings(rgame.app_name)
if pf.system() != "Windows":
class GameWineSettings(WineSettings):
def load_settings(self, app_name):
self.app_name = app_name
if pf.system() in {"Linux", "FreeBSD"}:
class GameProtonSettings(ProtonSettings):
def load_settings(self, app_name: str):
self.app_name = app_name
class GameMangoHudSettings(MangoHudSettings):
def load_settings(self, app_name: str):
self.app_name = app_name
class GameDxvkSettings(DxvkSettings):
def load_settings(self, app_name: str):
self.app_name = app_name
class GameEnvVars(EnvVars):
def load_settings(self, app_name):
self.app_name = app_name
class GameSettings(GameSettingsBase):
def __init__(self, parent=None):
if pf.system() != "Windows":
if pf.system() in {"Linux", "FreeBSD"}:
super(GameSettings, self).__init__(
GameLaunchSettings, GameDxvkSettings, GameEnvVars,
GameWineSettings, GameProtonSettings, GameMangoHudSettings,
parent=parent
)
else:
super(GameSettings, self).__init__(
GameLaunchSettings, GameDxvkSettings, GameEnvVars,
GameWineSettings,
parent=parent
)
else:
super(GameSettings, self).__init__(
GameLaunchSettings, GameDxvkSettings, GameEnvVars,
parent=parent
)
def load_settings(self, rgame: RareGame):
self.set_title.emit(rgame.app_title)
self.app_name = rgame.app_name
self.launch.load_settings(rgame)
if pf.system() != "Windows":
self.wine.load_settings(rgame.app_name)
if pf.system() in {"Linux", "FreeBSD"}:
self.proton_tool.load_settings(rgame.app_name)
self.mangohud.load_settings(rgame.app_name)
self.dxvk.load_settings(rgame.app_name)
self.env_vars.load_settings(rgame.app_name)

View file

@ -1,55 +1,50 @@
from typing import Tuple, List, Union, Optional
from abc import abstractmethod
from typing import Tuple, List, Union, Type, TypeVar
from PyQt5.QtCore import QObject, pyqtSlot
from PyQt5.QtWidgets import QWidget
from PyQt5.QtCore import QObject, pyqtSlot, Qt
from PyQt5.QtWidgets import QWidget, QVBoxLayout, QScrollArea
from rare.lgndr.core import LegendaryCore
from rare.models.game import RareGame
from rare.models.signals import GlobalSignals
from rare.models.library import LibraryFilter, LibraryOrder, LibraryView
from rare.shared import RareCore
from rare.widgets.library_layout import LibraryLayout
from .icon_game_widget import IconGameWidget
from .list_game_widget import ListGameWidget
ViewWidget = TypeVar("ViewWidget", IconGameWidget, ListGameWidget)
class LibraryWidgetController(QObject):
def __init__(self, icon_container: QWidget, list_container: QWidget, parent: QWidget = None):
super(LibraryWidgetController, self).__init__(parent=parent)
self._icon_container: QWidget = icon_container
self._list_container: QWidget = list_container
self.rcore = RareCore.instance()
self.core: LegendaryCore = self.rcore.core()
self.signals: GlobalSignals = self.rcore.signals()
self.signals.game.installed.connect(self.sort_list)
self.signals.game.uninstalled.connect(self.sort_list)
class ViewContainer(QWidget):
def __init__(self, rcore: RareCore, parent=None):
super().__init__(parent=parent)
self.rcore: RareCore = rcore
def add_game(self, rgame: RareGame):
return self.add_widgets(rgame)
def add_widgets(self, rgame: RareGame) -> Tuple[IconGameWidget, ListGameWidget]:
icon_widget = IconGameWidget(rgame, self._icon_container)
list_widget = ListGameWidget(rgame, self._list_container)
return icon_widget, list_widget
def _add_widget(self, widget_type: Type[ViewWidget], rgame: RareGame) -> ViewWidget:
widget = widget_type(rgame, self)
self.layout().addWidget(widget)
return widget
@staticmethod
def __visibility(widget: Union[IconGameWidget,ListGameWidget], filter_name, search_text) -> Tuple[bool, float]:
if filter_name == "hidden":
def __visibility(widget: ViewWidget, library_filter, search_text) -> Tuple[bool, float]:
if library_filter == LibraryFilter.HIDDEN:
visible = "hidden" in widget.rgame.metadata.tags
elif "hidden" in widget.rgame.metadata.tags:
visible = False
elif filter_name == "installed":
elif library_filter == LibraryFilter.INSTALLED:
visible = widget.rgame.is_installed and not widget.rgame.is_unreal
elif filter_name == "offline":
elif library_filter == LibraryFilter.OFFLINE:
visible = widget.rgame.can_run_offline and not widget.rgame.is_unreal
elif filter_name == "32bit":
elif library_filter == LibraryFilter.WIN32:
visible = widget.rgame.is_win32 and not widget.rgame.is_unreal
elif filter_name == "mac":
elif library_filter == LibraryFilter.MAC:
visible = widget.rgame.is_mac and not widget.rgame.is_unreal
elif filter_name == "installable":
elif library_filter == LibraryFilter.INSTALLABLE:
visible = not widget.rgame.is_non_asset and not widget.rgame.is_unreal
elif filter_name == "include_ue":
elif library_filter == LibraryFilter.INCLUDE_UE:
visible = True
elif filter_name == "all":
elif library_filter == LibraryFilter.ALL:
visible = not widget.rgame.is_unreal
else:
visible = True
@ -64,74 +59,159 @@ class LibraryWidgetController(QObject):
return visible, opacity
def filter_list(self, filter_name="all", search_text: str = ""):
icon_widgets = self._icon_container.findChildren(IconGameWidget)
list_widgets = self._list_container.findChildren(ListGameWidget)
for iw in icon_widgets:
visibility, opacity = self.__visibility(iw, filter_name, search_text)
def _filter_view(self, widget_type: Type[ViewWidget], filter_by: LibraryFilter = LibraryFilter.ALL, search_text: str = ""):
widgets = self.findChildren(widget_type)
for iw in widgets:
visibility, opacity = self.__visibility(iw, filter_by, search_text)
iw.setOpacity(opacity)
iw.setVisible(visibility)
for lw in list_widgets:
visibility, opacity = self.__visibility(lw, filter_name, search_text)
lw.setOpacity(opacity)
lw.setVisible(visibility)
self.sort_list(search_text)
def _update_view(self, widget_type: Type[ViewWidget]):
widgets = self.findChildren(widget_type)
app_names = {iw.rgame.app_name for iw in widgets}
games = list(self.rcore.games)
game_app_names = {g.app_name for g in games}
new_app_names = game_app_names.difference(app_names)
for app_name in new_app_names:
game = self.rcore.get_game(app_name)
w = widget_type(game, self)
self.layout().addWidget(w)
def _find_widget(self, widget_type: Type[ViewWidget], app_name: str) -> ViewWidget:
w = self.findChild(widget_type, app_name)
return w
@abstractmethod
def order_view(self):
pass
class IconViewContainer(ViewContainer):
def __init__(self, rcore: RareCore, parent=None):
super().__init__(rcore, parent=parent)
view_layout = LibraryLayout(self)
view_layout.setSpacing(9)
view_layout.setContentsMargins(0, 13, 0, 13)
view_layout.setAlignment(Qt.AlignTop)
self.setLayout(view_layout)
def add_widget(self, rgame: RareGame) -> IconGameWidget:
return self._add_widget(IconGameWidget, rgame)
def filter_view(self, filter_by: LibraryFilter = LibraryFilter.ALL, search_text: str = ""):
self._filter_view(IconGameWidget, filter_by, search_text)
def update_view(self):
self._update_view(IconGameWidget)
def find_widget(self, app_name: str) -> ViewWidget:
return self._find_widget(IconGameWidget, app_name)
def order_view(self, order_by: LibraryOrder = LibraryOrder.TITLE, search_text: str = ""):
if search_text:
self.layout().sort(
lambda x: (search_text not in x.widget().rgame.app_title.lower(),)
)
else:
if (newest := order_by == LibraryOrder.NEWEST) or order_by == LibraryOrder.OLDEST:
# Sort by grant date
self.layout().sort(
key=lambda x: (x.widget().rgame.is_installed, not x.widget().rgame.is_non_asset, x.widget().rgame.grant_date()),
reverse=newest,
)
elif order_by == LibraryOrder.RECENT:
# Sort by recently played
self.layout().sort(
key=lambda x: (not x.widget().rgame.is_installed, x.widget().rgame.is_non_asset, x.widget().rgame.metadata.last_played),
reverse=True,
)
else:
# Sort by title
self.layout().sort(
key=lambda x: (not x.widget().rgame.is_installed, x.widget().rgame.is_non_asset, x.widget().rgame.app_title)
)
class ListViewContainer(ViewContainer):
def __init__(self, rcore, parent=None):
super().__init__(rcore, parent=parent)
view_layout = QVBoxLayout(self)
view_layout.setContentsMargins(3, 3, 9, 3)
view_layout.setAlignment(Qt.AlignTop)
self.setLayout(view_layout)
def add_widget(self, rgame: RareGame) -> ListGameWidget:
return self._add_widget(ListGameWidget, rgame)
def filter_view(self, filter_by: LibraryFilter = LibraryFilter.ALL, search_text: str = ""):
self._filter_view(ListGameWidget, filter_by, search_text)
def update_view(self):
self._update_view(ListGameWidget)
def find_widget(self, app_name: str) -> ViewWidget:
return self._find_widget(ListGameWidget, app_name)
def order_view(self, order_by: LibraryOrder = LibraryOrder.TITLE, search_text: str = ""):
list_widgets = self.findChildren(ListGameWidget)
if search_text:
list_widgets.sort(key=lambda x: (search_text not in x.rgame.app_title.lower(),))
else:
if (newest := order_by == LibraryOrder.NEWEST) or order_by == LibraryOrder.OLDEST:
list_widgets.sort(
key=lambda x: (x.rgame.is_installed, not x.rgame.is_non_asset, x.rgame.grant_date()),
reverse=newest,
)
elif order_by == LibraryOrder.RECENT:
list_widgets.sort(
key=lambda x: (not x.rgame.is_installed, x.rgame.is_non_asset, x.rgame.metadata.last_played),
reverse=True,
)
else:
list_widgets.sort(
key=lambda x: (not x.rgame.is_installed, x.rgame.is_non_asset, x.rgame.app_title)
)
for idx, wl in enumerate(list_widgets):
self.layout().insertWidget(idx, wl)
class LibraryWidgetController(QObject):
def __init__(self, view: LibraryView, parent: QScrollArea = None):
super(LibraryWidgetController, self).__init__(parent=parent)
self.rcore = RareCore.instance()
self.core: LegendaryCore = self.rcore.core()
self.signals: GlobalSignals = self.rcore.signals()
if view == LibraryView.COVER:
self._container: IconViewContainer = IconViewContainer(self.rcore, parent)
else:
self._container: ListViewContainer = ListViewContainer(self.rcore, parent)
parent.setWidget(self._container)
self.signals.game.installed.connect(self.order_game_views)
self.signals.game.uninstalled.connect(self.order_game_views)
def add_game(self, rgame: RareGame):
return self.add_widgets(rgame)
def add_widgets(self, rgame: RareGame) -> ViewWidget:
return self._container.add_widget(rgame)
def filter_game_views(self, filter_by: LibraryFilter = LibraryFilter.ALL, search_text: str = ""):
self._container.filter_view(filter_by, search_text)
self.order_game_views(search_text=search_text)
@pyqtSlot()
def sort_list(self, sort_by: str = ""):
# lk: this is the existing sorting implemenation
# lk: it sorts by installed then by title
if sort_by:
self._icon_container.layout().sort(lambda x: (sort_by not in x.widget().rgame.app_title.lower(),))
else:
self._icon_container.layout().sort(
key=lambda x: (
# Sort by grant date
# x.widget().rgame.is_installed,
# not x.widget().rgame.is_non_asset,
# x.widget().rgame.grant_date(),
# ), reverse=True
not x.widget().rgame.is_installed,
x.widget().rgame.is_non_asset,
x.widget().rgame.app_title,
)
)
list_widgets = self._list_container.findChildren(ListGameWidget)
if sort_by:
list_widgets.sort(key=lambda x: (sort_by not in x.rgame.app_title.lower(),))
else:
list_widgets.sort(
# Sort by grant date
# key=lambda x: (x.rgame.is_installed, not x.rgame.is_non_asset, x.rgame.grant_date()), reverse=True
key=lambda x: (not x.rgame.is_installed, x.rgame.is_non_asset, x.rgame.app_title)
)
for idx, wl in enumerate(list_widgets):
self._list_container.layout().insertWidget(idx, wl)
def order_game_views(self, order_by: LibraryOrder = LibraryOrder.TITLE, search_text: str = ""):
self._container.order_view(order_by, search_text)
@pyqtSlot()
@pyqtSlot(list)
def update_list(self, app_names: List[str] = None):
if not app_names:
# lk: base it on icon widgets, the two lists should be identical
icon_widgets = self._icon_container.findChildren(IconGameWidget)
list_widgets = self._list_container.findChildren(ListGameWidget)
icon_app_names = set([iw.rgame.app_name for iw in icon_widgets])
list_app_names = set([lw.rgame.app_name for lw in list_widgets])
games = list(self.rcore.games)
game_app_names = set([g.app_name for g in games])
new_icon_app_names = game_app_names.difference(icon_app_names)
new_list_app_names = game_app_names.difference(list_app_names)
for app_name in new_icon_app_names:
game = self.rcore.get_game(app_name)
iw = IconGameWidget(game)
self._icon_container.layout().addWidget(iw)
for app_name in new_list_app_names:
game = self.rcore.get_game(app_name)
lw = ListGameWidget(game)
self._list_container.layout().addWidget(lw)
self.sort_list()
def update_game_views(self, app_names: List[str] = None):
if app_names:
return
self._container.update_view()
self.order_game_views()
def __find_widget(self, app_name: str) -> Tuple[Union[IconGameWidget, None], Union[ListGameWidget, None]]:
iw = self._icon_container.findChild(IconGameWidget, app_name)
lw = self._list_container.findChild(ListGameWidget, app_name)
return iw, lw
def __find_widget(self, app_name: str) -> Union[ViewWidget, None]:
return self._container.find_widget(app_name)

View file

@ -111,7 +111,6 @@ class GameWidget(LibraryWidget):
self.startTimer(random.randrange(42, 2361, 129), Qt.CoarseTimer)
# self.startTimer(random.randrange(42, 2361, 363), Qt.VeryCoarseTimer)
# self.rgame.load_pixmap()
# QTimer.singleShot(random.randrange(42, 2361, 7), Qt.VeryCoarseTimer, self.rgame.load_pixmap)
super().paintEvent(a0)
def timerEvent(self, a0):

View file

@ -10,7 +10,7 @@ from PyQt5.QtWidgets import (
QPushButton,
)
from rare.utils.misc import icon, widget_object_name
from rare.utils.misc import qta_icon, widget_object_name
from rare.widgets.elide_label import ElideLabel
@ -59,13 +59,13 @@ class IconWidget(object):
# play button
self.launch_btn = QPushButton(parent=self.mini_widget)
self.launch_btn.setObjectName(f"{type(self).__name__}Button")
self.launch_btn.setIcon(icon("ei.play-alt", color="white"))
self.launch_btn.setIcon(qta_icon("ei.play-alt", color="white"))
self.launch_btn.setIconSize(QSize(20, 20))
self.launch_btn.setFixedSize(QSize(widget.width() // 4, widget.width() // 4))
self.install_btn = QPushButton(parent=self.mini_widget)
self.install_btn.setObjectName(f"{type(self).__name__}Button")
self.install_btn.setIcon(icon("ri.install-fill", color="white"))
self.install_btn.setIcon(qta_icon("ri.install-fill", color="white"))
self.install_btn.setIconSize(QSize(20, 20))
self.install_btn.setFixedSize(QSize(widget.width() // 4, widget.width() // 4))

View file

@ -18,7 +18,7 @@ class ProgressLabel(QLabel):
def __center_on_parent(self):
fm = QFontMetrics(self.font())
rect = fm.boundingRect(f" {self.text()} ")
rect = fm.boundingRect(" 100% ")
rect.moveCenter(self.parent().contentsRect().center())
self.setGeometry(rect)
@ -52,7 +52,7 @@ class ProgressLabel(QLabel):
origin_h = (image.height() - min_d) // 2
for x, y in zip(range(origin_w, min_d), range(origin_h, min_d)):
pixel = image.pixelColor(x, y).getRgb()
color = list(map(lambda t: sum(t) // 2, zip(pixel[0:3], color)))
color = list(map(lambda t: sum(t) // 2, zip(pixel[:3], color)))
# take the V component of the HSV color
fg_color = QColor(0, 0, 0) if QColor(*color).value() < 127 else QColor(255, 255, 255)
bg_color = QColor(*map(lambda c: 255 - c, color))

View file

@ -9,12 +9,10 @@ from PyQt5.QtGui import (
QLinearGradient,
QPixmap,
QImage,
QResizeEvent,
)
from rare.models.game import RareGame
from rare.utils.misc import format_size
from rare.widgets.image_widget import ImageWidget
from .game_widget import GameWidget
from .list_widget import ListWidget
@ -70,23 +68,6 @@ class ListGameWidget(GameWidget):
refactored to be used in downloads and/or dlcs
"""
def event(self, e: QEvent) -> bool:
if e.type() == QEvent.LayoutRequest:
if self.progress_label.isVisible():
width = int(self._pixmap.width() / self._pixmap.devicePixelRatioF())
origin = self.width() - width
fill_rect = QRect(origin, 0, width, self.sizeHint().height())
self.progress_label.setGeometry(fill_rect)
return ImageWidget.event(self, e)
def resizeEvent(self, a0: QResizeEvent) -> None:
if self.progress_label.isVisible():
width = int(self._pixmap.width() / self._pixmap.devicePixelRatioF())
origin = self.width() - width
fill_rect = QRect(origin, 0, width, self.sizeHint().height())
self.progress_label.setGeometry(fill_rect)
ImageWidget.resizeEvent(self, a0)
def prepare_pixmap(self, pixmap: QPixmap) -> QPixmap:
device: QImage = QImage(
pixmap.size().width() * 3,
@ -112,11 +93,13 @@ class ListGameWidget(GameWidget):
# lk: trade some possible delay and start-up time
# lk: for faster rendering. Gradients are expensive
# lk: so pre-generate the image
super(ListGameWidget, self).setPixmap(self.prepare_pixmap(pixmap))
if not pixmap.isNull():
pixmap = self.prepare_pixmap(pixmap)
super(ListGameWidget, self).setPixmap(pixmap)
def paint_image_cover(self, painter: QPainter, a0: QPaintEvent) -> None:
painter.setOpacity(self._opacity)
color = self.palette().color(QPalette.Background).darker(75)
color = self.palette().color(QPalette.Window).darker(75)
painter.fillRect(self.rect(), color)
brush = QBrush(self._pixmap)
brush.setTransform(self._transform)

View file

@ -9,7 +9,7 @@ from PyQt5.QtWidgets import (
QWidget,
)
from rare.utils.misc import icon
from rare.utils.misc import qta_icon
from rare.widgets.elide_label import ElideLabel
@ -26,7 +26,7 @@ class ListWidget(object):
self.size_label = None
def setupUi(self, widget: QWidget):
self.title_label = QLabel(parent=widget)
self.title_label = ElideLabel(parent=widget)
self.title_label.setObjectName(f"{type(self).__name__}TitleLabel")
self.title_label.setWordWrap(False)
@ -40,14 +40,14 @@ class ListWidget(object):
self.install_btn = QPushButton(parent=widget)
self.install_btn.setObjectName(f"{type(self).__name__}Button")
self.install_btn.setIcon(icon("ri.install-line"))
self.install_btn.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed)
self.install_btn.setIcon(qta_icon("ri.install-line"))
self.install_btn.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding)
self.install_btn.setFixedWidth(120)
self.launch_btn = QPushButton(parent=widget)
self.launch_btn.setObjectName(f"{type(self).__name__}Button")
self.launch_btn.setIcon(icon("ei.play-alt"))
self.launch_btn.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed)
self.launch_btn.setIcon(qta_icon("ei.play-alt"))
self.launch_btn.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding)
self.launch_btn.setFixedWidth(120)
# lk: do not focus on button
@ -71,19 +71,19 @@ class ListWidget(object):
self.size_label.setFixedWidth(60)
# Create layouts
top_layout = QHBoxLayout()
top_layout.setAlignment(Qt.AlignLeft)
left_layout = QVBoxLayout()
left_layout.setAlignment(Qt.AlignLeft)
bottom_layout = QHBoxLayout()
bottom_layout.setAlignment(Qt.AlignRight)
layout = QVBoxLayout()
layout = QHBoxLayout()
layout.setSpacing(0)
layout.setContentsMargins(3, 3, 3, 3)
# Layout the widgets
# (from inner to outer)
top_layout.addWidget(self.title_label, stretch=1)
left_layout.addWidget(self.title_label, stretch=1)
bottom_layout.addWidget(self.developer_label, stretch=0, alignment=Qt.AlignLeft)
bottom_layout.addItem(QSpacerItem(20, 0, QSizePolicy.Fixed, QSizePolicy.Minimum))
@ -94,15 +94,17 @@ class ListWidget(object):
bottom_layout.addWidget(self.status_label, stretch=0, alignment=Qt.AlignLeft)
bottom_layout.addItem(QSpacerItem(20, 0, QSizePolicy.Expanding, QSizePolicy.Minimum))
bottom_layout.addWidget(self.tooltip_label, stretch=0, alignment=Qt.AlignRight)
bottom_layout.addWidget(self.install_btn, stretch=0, alignment=Qt.AlignRight)
bottom_layout.addWidget(self.launch_btn, stretch=0, alignment=Qt.AlignRight)
layout.addLayout(top_layout)
layout.addLayout(bottom_layout)
left_layout.addLayout(bottom_layout)
layout.addLayout(left_layout)
layout.addWidget(self.install_btn, stretch=0, alignment=Qt.AlignRight)
layout.addWidget(self.launch_btn, stretch=0, alignment=Qt.AlignRight)
widget.setLayout(layout)
widget.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Maximum)
widget.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed)
widget.setFixedHeight(widget.sizeHint().height())
widget.leaveEvent(None)
self.translateUi(widget)

View file

@ -1,25 +1,27 @@
import platform as pf
from PyQt5.QtCore import QSettings, pyqtSignal, pyqtSlot, Qt
from PyQt5.QtCore import QSettings, pyqtSignal, pyqtSlot, QSize, Qt
from PyQt5.QtWidgets import (
QLabel,
QPushButton,
QWidget,
QHBoxLayout,
QComboBox, QToolButton, QMenu, QAction,
QComboBox,
QMenu,
QAction, QSpacerItem, QSizePolicy,
)
from qtawesome import IconWidget
from rare.models.options import options, LibraryFilter, LibraryOrder
from rare.shared import RareCore
from rare.utils.extra_widgets import SelectViewWidget, ButtonLineEdit
from rare.utils.misc import icon
from rare.utils.extra_widgets import ButtonLineEdit
from rare.utils.misc import qta_icon
class GameListHeadBar(QWidget):
filterChanged: pyqtSignal = pyqtSignal(str)
goto_import: pyqtSignal = pyqtSignal()
goto_egl_sync: pyqtSignal = pyqtSignal()
goto_eos_ubisoft: pyqtSignal = pyqtSignal()
filterChanged = pyqtSignal(object)
orderChanged = pyqtSignal(object)
viewChanged = pyqtSignal(object)
goto_import = pyqtSignal()
goto_egl_sync = pyqtSignal()
goto_eos_ubisoft = pyqtSignal()
def __init__(self, parent=None):
super(GameListHeadBar, self).__init__(parent=parent)
@ -27,57 +29,87 @@ class GameListHeadBar(QWidget):
self.settings = QSettings(self)
self.filter = QComboBox(self)
self.filter.addItem(self.tr("All games"), "all")
self.filter.addItem(self.tr("Installed"), "installed")
self.filter.addItem(self.tr("Offline"), "offline")
# self.filter.addItem(self.tr("Hidden"), "hidden")
filters = {
LibraryFilter.ALL: self.tr("All games"),
LibraryFilter.INSTALLED: self.tr("Installed"),
LibraryFilter.OFFLINE: self.tr("Offline"),
# LibraryFilter.HIDDEN: self.tr("Hidden"),
}
for data, text in filters.items():
self.filter.addItem(text, data)
if self.rcore.bit32_games:
self.filter.addItem(self.tr("32bit games"), "32bit")
self.filter.addItem(self.tr("32bit games"), LibraryFilter.WIN32)
if self.rcore.mac_games:
self.filter.addItem(self.tr("macOS games"), "mac")
self.filter.addItem(self.tr("macOS games"), LibraryFilter.MAC)
if self.rcore.origin_games:
self.filter.addItem(self.tr("Exclude Origin"), "installable")
self.filter.addItem(self.tr("Include Unreal"), "include_ue")
self.filter.addItem(self.tr("Exclude Origin"), LibraryFilter.INSTALLABLE)
self.filter.addItem(self.tr("Include Unreal"), LibraryFilter.INCLUDE_UE)
filter_default = "mac" if pf.system() == "Darwin" else "all"
filter_index = i if (i := self.filter.findData(filter_default, Qt.UserRole)) >= 0 else 0
try:
self.filter.setCurrentIndex(self.settings.value("library_filter", filter_index, int))
except TypeError:
self.settings.setValue("library_filter", filter_index)
self.filter.setCurrentIndex(filter_index)
self.filter.currentIndexChanged.connect(self.filter_changed)
_filter = self.settings.value(*options.library_filter)
if (index := self.filter.findData(_filter, Qt.UserRole)) < 0:
raise ValueError
else:
self.filter.setCurrentIndex(index)
except (TypeError, ValueError):
self.settings.setValue(options.library_filter.key, options.library_filter.default)
_filter = LibraryFilter(options.library_filter.default)
self.filter.setCurrentIndex(self.filter.findData(_filter, Qt.UserRole))
self.filter.currentIndexChanged.connect(self.__filter_changed)
integrations_menu = QMenu(self)
import_action = QAction(icon("mdi.import", "fa.arrow-down"), self.tr("Import Game"), integrations_menu)
self.order = QComboBox(parent=self)
sortings = {
LibraryOrder.TITLE: self.tr("Title"),
LibraryOrder.RECENT: self.tr("Recently played"),
LibraryOrder.NEWEST: self.tr("Newest"),
LibraryOrder.OLDEST: self.tr("Oldest"),
}
for data, text in sortings.items():
self.order.addItem(text, data)
try:
_order = LibraryOrder(self.settings.value(*options.library_order))
if (index := self.order.findData(_order, Qt.UserRole)) < 0:
raise ValueError
else:
self.order.setCurrentIndex(index)
except (TypeError, ValueError):
self.settings.setValue(options.library_order.key, options.library_order.default)
_order = LibraryOrder(options.library_order.default)
self.order.setCurrentIndex(self.order.findData(_order, Qt.UserRole))
self.order.currentIndexChanged.connect(self.__order_changed)
integrations_menu = QMenu(parent=self)
import_action = QAction(
qta_icon("mdi.import", "fa.arrow-down"), self.tr("Import Game"), integrations_menu
)
import_action.triggered.connect(self.goto_import)
egl_sync_action = QAction(icon("mdi.sync", "fa.refresh"), self.tr("Sync with EGL"), integrations_menu)
egl_sync_action = QAction(qta_icon("mdi.sync", "fa.refresh"), self.tr("Sync with EGL"), integrations_menu)
egl_sync_action.triggered.connect(self.goto_egl_sync)
eos_ubisoft_action = QAction(icon("mdi.rocket", "fa.rocket"), self.tr("Epic Overlay and Ubisoft"),
integrations_menu)
eos_ubisoft_action = QAction(
qta_icon("mdi.rocket", "fa.rocket"), self.tr("Epic Overlay and Ubisoft"), integrations_menu
)
eos_ubisoft_action.triggered.connect(self.goto_eos_ubisoft)
integrations_menu.addAction(import_action)
integrations_menu.addAction(egl_sync_action)
integrations_menu.addAction(eos_ubisoft_action)
integrations = QToolButton(self)
integrations = QPushButton(parent=self)
integrations.setText(self.tr("Integrations"))
integrations.setMenu(integrations_menu)
integrations.setPopupMode(QToolButton.InstantPopup)
self.search_bar = ButtonLineEdit("fa.search", placeholder_text=self.tr("Search Game"))
self.search_bar = ButtonLineEdit("fa.search", placeholder_text=self.tr("Search"))
self.search_bar.setSizePolicy(QSizePolicy.Maximum, QSizePolicy.Preferred)
self.search_bar.setObjectName("SearchBar")
self.search_bar.setFrame(False)
self.search_bar.setMinimumWidth(200)
checked = QSettings().value("icon_view", True, bool)
self.search_bar.setMinimumWidth(250)
installed_tooltip = self.tr("Installed games")
self.installed_icon = IconWidget(parent=self)
self.installed_icon.setIcon(icon("ph.floppy-disk-back-fill"))
self.installed_icon = QLabel(parent=self)
self.installed_icon.setPixmap(qta_icon("ph.floppy-disk-back-fill").pixmap(QSize(16, 16)))
self.installed_icon.setToolTip(installed_tooltip)
self.installed_label = QLabel(parent=self)
font = self.installed_label.font()
@ -85,45 +117,52 @@ class GameListHeadBar(QWidget):
self.installed_label.setFont(font)
self.installed_label.setToolTip(installed_tooltip)
available_tooltip = self.tr("Available games")
self.available_icon = IconWidget(parent=self)
self.available_icon.setIcon(icon("ph.floppy-disk-back-light"))
self.available_icon = QLabel(parent=self)
self.available_icon.setPixmap(qta_icon("ph.floppy-disk-back-light").pixmap(QSize(16, 16)))
self.available_icon.setToolTip(available_tooltip)
self.available_label = QLabel(parent=self)
self.available_label.setToolTip(available_tooltip)
self.view = SelectViewWidget(checked)
self.refresh_list = QPushButton(parent=self)
self.refresh_list.setIcon(qta_icon("fa.refresh")) # Reload icon
self.refresh_list.clicked.connect(self.__refresh_clicked)
self.refresh_list = QPushButton()
self.refresh_list.setIcon(icon("fa.refresh")) # Reload icon
self.refresh_list.clicked.connect(self.refresh_clicked)
layout = QHBoxLayout()
layout.setContentsMargins(0, 5, 0, 5)
layout = QHBoxLayout(self)
layout.setContentsMargins(0, 0, 0, 0)
layout.addWidget(self.filter)
layout.addStretch(0)
layout.addWidget(integrations)
layout.addStretch(5)
layout.addWidget(self.order)
layout.addSpacerItem(QSpacerItem(0, 0, QSizePolicy.Expanding, QSizePolicy.Fixed))
layout.addWidget(self.search_bar)
layout.addStretch(2)
layout.addWidget(self.installed_icon)
layout.addWidget(self.installed_label)
layout.addWidget(self.available_icon)
layout.addWidget(self.available_label)
layout.addStretch(2)
layout.addWidget(self.view)
layout.addStretch(2)
layout.addSpacerItem(QSpacerItem(0, 0, QSizePolicy.Expanding, QSizePolicy.Fixed))
layout.addWidget(integrations)
layout.addWidget(self.refresh_list)
self.setLayout(layout)
def set_games_count(self, inst: int, avail: int) -> None:
self.installed_label.setText(str(inst))
self.available_label.setText(str(avail))
@pyqtSlot()
def refresh_clicked(self):
def __refresh_clicked(self):
self.rcore.fetch()
def current_filter(self) -> LibraryFilter:
return self.filter.currentData(Qt.UserRole)
@pyqtSlot(int)
def filter_changed(self, index: int):
self.filterChanged.emit(self.filter.itemData(index, Qt.UserRole))
self.settings.setValue("library_filter", index)
def __filter_changed(self, index: int):
data = self.filter.itemData(index, Qt.UserRole)
self.filterChanged.emit(data)
self.settings.setValue(options.library_filter.key, int(data))
def current_order(self) -> LibraryOrder:
return self.order.currentData(Qt.UserRole)
@pyqtSlot(int)
def __order_changed(self, index: int):
data = self.order.itemData(index, Qt.UserRole)
self.orderChanged.emit(data)
self.settings.setValue(options.library_order.key, int(data))

View file

@ -13,9 +13,10 @@ from legendary.models.game import InstalledGame
from rare.lgndr.glue.exception import LgndrException
from rare.models.pathspec import PathSpec
from rare.shared import RareCore
from rare.shared.workers.wine_resolver import WineResolver
from rare.shared.workers.wine_resolver import WinePathResolver
from rare.ui.components.tabs.games.integrations.egl_sync_group import Ui_EGLSyncGroup
from rare.ui.components.tabs.games.integrations.egl_sync_list_group import Ui_EGLSyncListGroup
from rare.utils.compat import utils as compat_utils
from rare.widgets.elide_label import ElideLabel
from rare.widgets.indicator_edit import PathEdit, IndicatorReasonsCommon
@ -45,7 +46,6 @@ class EGLSyncGroup(QGroupBox):
)
self.egl_path_info = ElideLabel(parent=self)
self.egl_path_info.setProperty("infoLabel", 1)
self.ui.egl_sync_layout.setWidget(
self.ui.egl_sync_layout.getWidgetPosition(self.ui.egl_path_info_label)[0],
QFormLayout.FieldRole, self.egl_path_info
@ -87,11 +87,7 @@ class EGLSyncGroup(QGroupBox):
def __run_wine_resolver(self):
self.egl_path_info.setText(self.tr("Updating..."))
wine_resolver = WineResolver(
self.core,
PathSpec.egl_programdata,
"default"
)
wine_resolver = WinePathResolver(self.core, "default", str(PathSpec.egl_programdata()))
wine_resolver.signals.result_ready.connect(self.__on_wine_resolver_result)
QThreadPool.globalInstance().start(wine_resolver)
@ -122,14 +118,8 @@ class EGLSyncGroup(QGroupBox):
os.path.join(path, "dosdevices/c:")
):
# path is a wine prefix
path = os.path.join(
path,
"dosdevices/c:",
"ProgramData/Epic/EpicGamesLauncher/Data/Manifests",
)
elif not path.rstrip("/").endswith(
"ProgramData/Epic/EpicGamesLauncher/Data/Manifests"
):
path = PathSpec.prefix_egl_programdata(path)
elif not path.rstrip("/").endswith(PathSpec.wine_egl_programdata()):
# lower() might or might not be needed in the check
return False, path, IndicatorReasonsCommon.WRONG_FORMAT
if os.path.exists(path):
@ -311,7 +301,8 @@ class EGLSyncListGroup(QGroupBox):
def items(self) -> Iterable[EGLSyncListItem]:
# for i in range(self.list.count()):
# yield self.list.item(i)
return [self.ui.list.item(i) for i in range(self.ui.list.count())]
return map(self.ui.list.item, range(self.ui.list.count()))
# return [self.ui.list.item(i) for i in range(self.ui.list.count())]
class EGLSyncExportGroup(EGLSyncListGroup):

View file

@ -22,7 +22,7 @@ from rare.models.game import RareEosOverlay
from rare.shared import RareCore
from rare.ui.components.tabs.games.integrations.eos_widget import Ui_EosWidget
from rare.utils import config_helper as config
from rare.utils.misc import icon
from rare.utils.misc import qta_icon
from rare.widgets.elide_label import ElideLabel
logger = getLogger("EpicOverlay")
@ -102,15 +102,15 @@ class EosPrefixWidget(QFrame):
if not self.overlay.is_installed and not self.overlay.available_paths(self.prefix):
self.setDisabled(True)
self.indicator.setPixmap(icon("fa.circle-o", color="grey").pixmap(20, 20))
self.indicator.setPixmap(qta_icon("fa.circle-o", color="grey").pixmap(20, 20))
self.overlay_label.setText(self.overlay.active_path(self.prefix))
self.button.setText(self.tr("Unavailable"))
return
if self.overlay.is_enabled(self.prefix):
self.indicator.setPixmap(icon("fa.check-circle-o", color="green").pixmap(QSize(20, 20)))
self.indicator.setPixmap(qta_icon("fa.check-circle-o", color="green").pixmap(QSize(20, 20)))
else:
self.indicator.setPixmap(icon("fa.times-circle-o", color="red").pixmap(QSize(20, 20)))
self.indicator.setPixmap(qta_icon("fa.times-circle-o", color="red").pixmap(QSize(20, 20)))
install_path = os.path.normpath(p) if (p := self.overlay.install_path) else ""
@ -171,8 +171,8 @@ class EosGroup(QGroupBox):
self.ui.install_page_layout.setAlignment(Qt.AlignTop)
self.ui.info_page_layout.setAlignment(Qt.AlignTop)
self.ui.install_button.setIcon(icon("ri.install-line"))
self.ui.uninstall_button.setIcon(icon("ri.uninstall-line"))
self.ui.install_button.setIcon(qta_icon("ri.install-line"))
self.ui.uninstall_button.setIcon(qta_icon("ri.uninstall-line"))
self.installed_path_label = ElideLabel(parent=self)
self.installed_version_label = ElideLabel(parent=self)
@ -224,7 +224,7 @@ class EosGroup(QGroupBox):
if platform.system() != "Windows":
prefixes = config.get_prefixes()
prefixes = {prefix for prefix in prefixes if config.prefix_exists(prefix)}
prefixes = {prefix for prefix, _ in prefixes if config.prefix_exists(prefix)}
if platform.system() == "Darwin":
# TODO: add crossover support
pass

View file

@ -263,13 +263,12 @@ class ImportGroup(QGroupBox):
self.app_name_edit.setText(app_name)
def path_edit_callback(self, path) -> Tuple[bool, str, int]:
if os.path.exists(path):
if os.path.exists(os.path.join(path, ".egstore")):
return True, path, IndicatorReasonsCommon.VALID
elif os.path.basename(path) in self.__install_dirs:
return True, path, IndicatorReasonsCommon.VALID
else:
if not os.path.exists(path):
return False, path, IndicatorReasonsCommon.DIR_NOT_EXISTS
if os.path.exists(os.path.join(path, ".egstore")):
return True, path, IndicatorReasonsCommon.VALID
elif os.path.basename(path) in self.__install_dirs:
return True, path, IndicatorReasonsCommon.VALID
return False, path, IndicatorReasonsCommon.UNDEFINED
@pyqtSlot(str)

View file

@ -20,7 +20,7 @@ from rare.lgndr.core import LegendaryCore
from rare.shared import RareCore
from rare.shared.workers.worker import Worker
from rare.utils.metrics import timelogger
from rare.utils.misc import icon
from rare.utils.misc import qta_icon
from rare.widgets.elide_label import ElideLabel
from rare.widgets.loading_widget import LoadingWidget
@ -104,7 +104,7 @@ class UbiLinkWidget(QFrame):
self.ubi_account_id = ubi_account_id
self.ok_indicator = QLabel(parent=self)
self.ok_indicator.setPixmap(icon("fa.circle-o", color="grey").pixmap(20, 20))
self.ok_indicator.setPixmap(qta_icon("fa.circle-o", color="grey").pixmap(20, 20))
self.ok_indicator.setSizePolicy(QSizePolicy.Maximum, QSizePolicy.Preferred)
self.title_label = ElideLabel(game.app_title, parent=self)
@ -116,7 +116,7 @@ class UbiLinkWidget(QFrame):
if activated:
self.link_button.setText(self.tr("Already activated"))
self.link_button.setDisabled(True)
self.ok_indicator.setPixmap(icon("fa.check-circle-o", color="green").pixmap(QSize(20, 20)))
self.ok_indicator.setPixmap(qta_icon("fa.check-circle-o", color="green").pixmap(QSize(20, 20)))
layout = QHBoxLayout(self)
layout.setContentsMargins(-1, 0, 0, 0)
@ -127,7 +127,7 @@ class UbiLinkWidget(QFrame):
def activate(self):
self.link_button.setDisabled(True)
# self.ok_indicator.setPixmap(icon("mdi.loading", color="grey").pixmap(20, 20))
self.ok_indicator.setPixmap(icon("mdi.transit-connection-horizontal", color="grey").pixmap(20, 20))
self.ok_indicator.setPixmap(qta_icon("mdi.transit-connection-horizontal", color="grey").pixmap(20, 20))
if self.args.debug:
worker = UbiConnectWorker(RareCore.instance().core(), None, None)
@ -140,11 +140,11 @@ class UbiLinkWidget(QFrame):
def worker_finished(self, error):
if not error:
self.ok_indicator.setPixmap(icon("fa.check-circle-o", color="green").pixmap(QSize(20, 20)))
self.ok_indicator.setPixmap(qta_icon("fa.check-circle-o", color="green").pixmap(QSize(20, 20)))
self.link_button.setDisabled(True)
self.link_button.setText(self.tr("Already activated"))
else:
self.ok_indicator.setPixmap(icon("fa.times-circle-o", color="red").pixmap(QSize(20, 20)))
self.ok_indicator.setPixmap(qta_icon("fa.times-circle-o", color="red").pixmap(QSize(20, 20)))
self.ok_indicator.setToolTip(error)
self.link_button.setText(self.tr("Try again"))
self.link_button.setDisabled(False)
@ -243,15 +243,14 @@ class UbisoftGroup(QGroupBox):
if not uplay_games:
self.info_label.setText(self.tr("You don't own any Ubisoft games."))
elif activated == len(uplay_games):
self.info_label.setText(self.tr("All your Ubisoft games have already been activated."))
else:
if activated == len(uplay_games):
self.info_label.setText(self.tr("All your Ubisoft games have already been activated."))
else:
self.info_label.setText(
self.tr("You have <b>{}</b> games available to redeem.").format(
len(uplay_games) - activated
)
self.info_label.setText(
self.tr("You have <b>{}</b> games available to redeem.").format(
len(uplay_games) - activated
)
)
logger.info(f"Found {len(uplay_games) - activated} game(s) to redeem.")
self.loading_widget.stop()

View file

@ -1,9 +1,8 @@
from rare.components.tabs.settings.widgets.linux import LinuxSettings
from rare.shared import ArgumentsSingleton
from rare.widgets.side_tab import SideTabWidget
from .about import About
from .debug import DebugSettings
from .game_settings import DefaultGameSettings
from .settings import GameSettings
from .legendary import LegendarySettings
from .rare import RareSettings
@ -13,17 +12,24 @@ class SettingsTab(SideTabWidget):
super(SettingsTab, self).__init__(parent=parent)
self.args = ArgumentsSingleton()
self.rare_index = self.addTab(RareSettings(self), "Rare")
self.legendary_index = self.addTab(LegendarySettings(self), "Legendary")
self.settings_index = self.addTab(DefaultGameSettings(True, self), self.tr("Default Settings"))
rare_settings = RareSettings(self)
self.rare_index = self.addTab(rare_settings, "Rare")
legendary_settings = LegendarySettings(self)
self.legendary_index = self.addTab(legendary_settings, "Legendary")
game_settings = GameSettings(self)
self.settings_index = self.addTab(game_settings, self.tr("Defaults"))
self.about = About(self)
self.about_index = self.addTab(self.about, "About", "About")
title = self.tr("About")
self.about_index = self.addTab(self.about, title, title)
self.about.update_available_ready.connect(
lambda: self.tabBar().setTabText(self.about_index, "About (!)")
)
if self.args.debug:
self.debug_index = self.addTab(DebugSettings(self), "Debug")
title = self.tr("Debug")
self.debug_index = self.addTab(DebugSettings(self), title, title)
self.setCurrentIndex(self.rare_index)

View file

@ -16,7 +16,7 @@ def versiontuple(v):
try:
return tuple(map(int, (v.split("."))))
except Exception:
return tuple((9, 9, 9)) # It is a beta version and newer
return 9, 9, 9
class About(QWidget):
@ -63,7 +63,7 @@ class About(QWidget):
if self.update_available:
logger.info(f"Update available: {__version__} -> {latest_tag}")
self.ui.update_lbl.setText("{} -> {}".format(__version__, latest_tag))
self.ui.update_lbl.setText(f"{__version__} -> {latest_tag}")
self.update_available_ready.emit()
else:
self.ui.update_lbl.setText(self.tr("You have the latest version"))

View file

@ -1,106 +0,0 @@
import platform
from logging import getLogger
from PyQt5.QtCore import QSettings, Qt
from PyQt5.QtWidgets import (
QWidget,
QLabel
)
from rare.components.tabs.settings.widgets.env_vars import EnvVars
from rare.components.tabs.settings.widgets.wrapper import WrapperSettings
from rare.shared import LegendaryCoreSingleton
from rare.ui.components.tabs.settings.game_settings import Ui_GameSettings
if platform.system() != "Windows":
from rare.components.tabs.settings.widgets.linux import LinuxSettings
if platform.system() != "Darwin":
from rare.components.tabs.settings.widgets.proton import ProtonSettings
logger = getLogger("GameSettings")
class DefaultGameSettings(QWidget):
# variable to no update when changing game
change = False
app_name: str
def __init__(self, is_default, parent=None):
super(DefaultGameSettings, self).__init__(parent=parent)
self.ui = Ui_GameSettings()
self.ui.setupUi(self)
self.core = LegendaryCoreSingleton()
self.settings = QSettings()
self.wrapper_settings = WrapperSettings()
self.ui.launch_settings_group.layout().addRow(
QLabel("Wrapper"), self.wrapper_settings
)
self.env_vars = EnvVars(self)
self.ui.game_settings_layout.addWidget(self.env_vars)
if platform.system() != "Windows":
self.linux_settings = LinuxAppSettings()
if platform.system() != "Darwin":
self.proton_settings = ProtonSettings(self.linux_settings, self.wrapper_settings)
self.ui.proton_layout.addWidget(self.proton_settings)
self.proton_settings.environ_changed.connect(self.env_vars.reset_model)
# FIXME: Remove the spacerItem and margins from the linux settings
# FIXME: This should be handled differently at soem point in the future
# NOTE: specerItem has been removed
self.linux_settings.layout().setContentsMargins(0, 0, 0, 0)
# FIXME: End of FIXME
self.ui.linux_settings_layout.addWidget(self.linux_settings)
self.ui.linux_settings_layout.setAlignment(Qt.AlignTop)
self.ui.game_settings_layout.setAlignment(Qt.AlignTop)
self.linux_settings.mangohud.set_wrapper_activated.connect(
lambda active: self.wrapper_settings.add_wrapper("mangohud")
if active else self.wrapper_settings.delete_wrapper("mangohud"))
self.linux_settings.environ_changed.connect(self.env_vars.reset_model)
else:
self.ui.linux_settings_widget.setVisible(False)
if is_default:
self.ui.launch_settings_layout.removeRow(self.ui.skip_update)
self.ui.launch_settings_layout.removeRow(self.ui.offline)
self.ui.launch_settings_layout.removeRow(self.ui.launch_params)
self.load_settings("default")
def load_settings(self, app_name):
self.app_name = app_name
self.wrapper_settings.load_settings(app_name)
if platform.system() != "Windows":
self.linux_settings.update_game(app_name)
proton = self.wrapper_settings.wrappers.get("proton", "")
if proton:
proton = proton.text
if platform.system() != "Darwin":
self.proton_settings.load_settings(app_name, proton)
else:
proton = ""
if proton:
self.linux_settings.ui.wine_groupbox.setEnabled(False)
else:
self.linux_settings.ui.wine_groupbox.setEnabled(True)
self.env_vars.update_game(app_name)
if platform.system() != "Windows":
class LinuxAppSettings(LinuxSettings):
def __init__(self):
super(LinuxAppSettings, self).__init__()
def update_game(self, app_name):
self.name = app_name
self.wine_prefix.setText(self.load_prefix())
self.wine_exec.setText(self.load_setting(self.name, "wine_executable"))
self.dxvk.load_settings(self.name)
self.mangohud.load_settings(self.name)

View file

@ -1,11 +1,13 @@
import platform as pf
import re
from logging import getLogger
from typing import Tuple, List
from typing import Tuple, Set
from PyQt5.QtCore import QObject, pyqtSignal, QThreadPool, QSettings
from PyQt5.QtGui import QShowEvent, QHideEvent
from PyQt5.QtWidgets import QSizePolicy, QWidget, QFileDialog, QMessageBox
from rare.models.options import options
from rare.shared import LegendaryCoreSingleton
from rare.shared.workers.worker import Worker
from rare.ui.components.tabs.settings.legendary import Ui_LegendarySettings
@ -19,14 +21,11 @@ class RefreshGameMetaWorker(Worker):
class Signals(QObject):
finished = pyqtSignal()
def __init__(self, platforms: List[str], include_unreal: bool):
def __init__(self, platforms: Set[str], include_unreal: bool):
super(RefreshGameMetaWorker, self).__init__()
self.signals = RefreshGameMetaWorker.Signals()
self.core = LegendaryCoreSingleton()
if platforms:
self.platforms = platforms
else:
self.platforms = ["Windows"]
self.platforms = platforms if platforms else {"Windows"}
self.skip_ue = not include_unreal
def run_real(self) -> None:
@ -37,10 +36,11 @@ class RefreshGameMetaWorker(Worker):
self.signals.finished.emit()
class LegendarySettings(QWidget, Ui_LegendarySettings):
class LegendarySettings(QWidget):
def __init__(self, parent=None):
super(LegendarySettings, self).__init__(parent=parent)
self.setupUi(self)
self.ui = Ui_LegendarySettings()
self.ui.setupUi(self)
self.settings = QSettings(self)
self.core = LegendaryCoreSingleton()
@ -53,7 +53,7 @@ class LegendarySettings(QWidget, Ui_LegendarySettings):
file_mode=QFileDialog.DirectoryOnly,
save_func=self.__mac_path_save,
)
self.install_dir_layout.addWidget(self.mac_install_dir)
self.ui.install_dir_layout.addWidget(self.mac_install_dir)
# Platform-independent installation directory
self.install_dir = PathEdit(
@ -62,34 +62,34 @@ class LegendarySettings(QWidget, Ui_LegendarySettings):
file_mode=QFileDialog.DirectoryOnly,
save_func=self.__win_path_save,
)
self.install_dir_layout.addWidget(self.install_dir)
self.ui.install_dir_layout.addWidget(self.install_dir)
# Max Workers
max_workers = self.core.lgd.config["Legendary"].getint(
"max_workers", fallback=0
)
self.max_worker_spin.setValue(max_workers)
self.max_worker_spin.valueChanged.connect(self.max_worker_save)
self.ui.max_worker_spin.setValue(max_workers)
self.ui.max_worker_spin.valueChanged.connect(self.max_worker_save)
# Max memory
max_memory = self.core.lgd.config["Legendary"].getint("max_memory", fallback=0)
self.max_memory_spin.setValue(max_memory)
self.max_memory_spin.valueChanged.connect(self.max_memory_save)
self.ui.max_memory_spin.setValue(max_memory)
self.ui.max_memory_spin.valueChanged.connect(self.max_memory_save)
# Preferred CDN
preferred_cdn = self.core.lgd.config["Legendary"].get(
"preferred_cdn", fallback=""
)
self.preferred_cdn_line.setText(preferred_cdn)
self.preferred_cdn_line.textChanged.connect(self.preferred_cdn_save)
self.ui.preferred_cdn_line.setText(preferred_cdn)
self.ui.preferred_cdn_line.textChanged.connect(self.preferred_cdn_save)
# Disable HTTPS
disable_https = self.core.lgd.config["Legendary"].getboolean(
"disable_https", fallback=False
)
self.disable_https_check.setChecked(disable_https)
self.disable_https_check.stateChanged.connect(self.disable_https_save)
self.ui.disable_https_check.setChecked(disable_https)
self.ui.disable_https_check.stateChanged.connect(self.disable_https_save)
# Cleanup
self.clean_button.clicked.connect(lambda: self.cleanup(False))
self.clean_keep_manifests_button.clicked.connect(lambda: self.cleanup(True))
self.ui.clean_button.clicked.connect(lambda: self.cleanup(False))
self.ui.clean_keep_manifests_button.clicked.connect(lambda: self.cleanup(True))
self.locale_edit = IndicatorLineEdit(
f"{self.core.language_code}-{self.core.country_code}",
@ -98,58 +98,66 @@ class LegendarySettings(QWidget, Ui_LegendarySettings):
horiz_policy=QSizePolicy.Minimum,
parent=self,
)
self.locale_layout.addWidget(self.locale_edit)
self.ui.locale_layout.addWidget(self.locale_edit)
self.fetch_win32_check.setChecked(self.settings.value("win32_meta", False, bool))
self.fetch_win32_check.stateChanged.connect(
lambda: self.settings.setValue("win32_meta", self.fetch_win32_check.isChecked())
self.ui.fetch_win32_check.setChecked(self.settings.value(*options.win32_meta))
self.ui.fetch_win32_check.stateChanged.connect(
lambda: self.settings.setValue(options.win32_meta.key, self.ui.fetch_win32_check.isChecked())
)
self.fetch_macos_check.setChecked(self.settings.value("macos_meta", pf.system() == "Darwin", bool))
self.fetch_macos_check.stateChanged.connect(
lambda: self.settings.setValue("macos_meta", self.fetch_macos_check.isChecked())
self.ui.fetch_macos_check.setChecked(self.settings.value(*options.macos_meta))
self.ui.fetch_macos_check.stateChanged.connect(
lambda: self.settings.setValue(options.macos_meta.key, self.ui.fetch_macos_check.isChecked())
)
self.fetch_macos_check.setDisabled(pf.system() == "Darwin")
self.ui.fetch_macos_check.setDisabled(pf.system() == "Darwin")
self.fetch_unreal_check.setChecked(self.settings.value("unreal_meta", False, bool))
self.fetch_unreal_check.stateChanged.connect(
lambda: self.settings.setValue("unreal_meta", self.fetch_unreal_check.isChecked())
self.ui.fetch_unreal_check.setChecked(self.settings.value(*options.unreal_meta))
self.ui.fetch_unreal_check.stateChanged.connect(
lambda: self.settings.setValue(options.unreal_meta.key, self.ui.fetch_unreal_check.isChecked())
)
self.exclude_non_asset_check.setChecked(
self.settings.value("exclude_non_asset", False, bool)
)
self.exclude_non_asset_check.stateChanged.connect(
lambda: self.settings.setValue("exclude_non_asset", self.exclude_non_asset_check.isChecked())
)
self.exclude_entitlements_check.setChecked(
self.settings.value("exclude_entitlements", False, bool)
)
self.exclude_entitlements_check.stateChanged.connect(
lambda: self.settings.setValue("exclude_entitlements", self.exclude_entitlements_check.isChecked())
self.ui.exclude_non_asset_check.setChecked(self.settings.value(*options.exclude_non_asset))
self.ui.exclude_non_asset_check.stateChanged.connect(
lambda: self.settings.setValue(options.exclude_non_asset.key, self.ui.exclude_non_asset_check.isChecked())
)
self.refresh_metadata_button.clicked.connect(self.refresh_metadata)
self.ui.exclude_entitlements_check.setChecked(self.settings.value(*options.exclude_entitlements))
self.ui.exclude_entitlements_check.stateChanged.connect(
lambda: self.settings.setValue(options.exclude_entitlements.key, self.ui.exclude_entitlements_check.isChecked())
)
self.ui.refresh_metadata_button.clicked.connect(self.refresh_metadata)
# FIXME: Disable the button for now because it interferes with RareCore
self.refresh_metadata_button.setEnabled(False)
self.refresh_metadata_button.setVisible(False)
self.ui.refresh_metadata_button.setEnabled(False)
self.ui.refresh_metadata_button.setVisible(False)
def showEvent(self, a0: QShowEvent):
if a0.spontaneous():
return super().showEvent(a0)
return super().showEvent(a0)
def hideEvent(self, a0: QHideEvent):
if a0.spontaneous():
return super().hideEvent(a0)
self.core.lgd.save_config()
return super().hideEvent(a0)
def refresh_metadata(self):
self.refresh_metadata_button.setDisabled(True)
platforms = []
if self.fetch_win32_check.isChecked():
platforms.append("Win32")
if self.fetch_macos_check.isChecked():
platforms.append("Mac")
worker = RefreshGameMetaWorker(platforms, self.fetch_unreal_check.isChecked())
worker.signals.finished.connect(lambda: self.refresh_metadata_button.setDisabled(False))
self.ui.refresh_metadata_button.setDisabled(True)
platforms = set()
if self.ui.fetch_win32_check.isChecked():
platforms.add("Win32")
if self.ui.fetch_macos_check.isChecked():
platforms.add("Mac")
worker = RefreshGameMetaWorker(platforms, self.ui.fetch_unreal_check.isChecked())
worker.signals.finished.connect(lambda: self.ui.refresh_metadata_button.setDisabled(False))
QThreadPool.globalInstance().start(worker)
@staticmethod
def locale_edit_cb(text: str) -> Tuple[bool, str, int]:
if text:
if re.match("^[a-zA-Z]{2,3}[-_][a-zA-Z]{2,3}$", text):
language, country = text.replace("_", "-").split("-")
language, country = text.split("-" if "-" in text else "_")
text = "-".join([language.lower(), country.upper()])
if bool(re.match("^[a-z]{2,3}-[A-Z]{2,3}$", text)):
return True, text, IndicatorReasonsCommon.VALID
@ -162,10 +170,8 @@ class LegendarySettings(QWidget, Ui_LegendarySettings):
if text:
self.core.egs.language_code, self.core.egs.country_code = text.split("-")
self.core.lgd.config.set("Legendary", "locale", text)
else:
if self.core.lgd.config.has_option("Legendary", "locale"):
self.core.lgd.config.remove_option("Legendary", "locale")
self.core.lgd.save_config()
elif self.core.lgd.config.has_option("Legendary", "locale"):
self.core.lgd.config.remove_option("Legendary", "locale")
def __mac_path_save(self, text: str) -> None:
self.__path_save(text, "mac_install_dir")
@ -180,40 +186,35 @@ class LegendarySettings(QWidget, Ui_LegendarySettings):
if not text and option in self.core.lgd.config["Legendary"].keys():
self.core.lgd.config["Legendary"].pop(option)
else:
logger.debug(f"Set %s option in config to %s", option, text)
self.core.lgd.save_config()
logger.debug("Set %s option in config to %s", option, text)
def max_worker_save(self, workers: str):
if workers := int(workers):
self.core.lgd.config.set("Legendary", "max_workers", str(workers))
else:
self.core.lgd.config.remove_option("Legendary", "max_workers")
self.core.lgd.save_config()
def max_memory_save(self, memory: str):
if memory := int(memory):
self.core.lgd.config.set("Legendary", "max_memory", str(memory))
else:
self.core.lgd.config.remove_option("Legendary", "max_memory")
self.core.lgd.save_config()
def preferred_cdn_save(self, cdn: str):
if cdn:
self.core.lgd.config.set("Legendary", "preferred_cdn", cdn.strip())
else:
self.core.lgd.config.remove_option("Legendary", "preferred_cdn")
self.core.lgd.save_config()
def disable_https_save(self, checked: int):
self.core.lgd.config.set(
"Legendary", "disable_https", str(bool(checked)).lower()
)
self.core.lgd.save_config()
def cleanup(self, keep_manifests: bool):
before = self.core.lgd.get_dir_size()
logger.debug("Removing app metadata...")
app_names = set(g.app_name for g in self.core.get_assets(update_assets=False))
app_names = {g.app_name for g in self.core.get_assets(update_assets=False)}
self.core.lgd.clean_metadata(app_names)
if not keep_manifests:

View file

@ -1,13 +1,13 @@
import os
import platform
import subprocess
import sys
import locale
from logging import getLogger
from PyQt5.QtCore import QSettings, Qt
from PyQt5.QtCore import QSettings, Qt, pyqtSlot, QUrl
from PyQt5.QtGui import QDesktopServices
from PyQt5.QtWidgets import QWidget, QMessageBox
from rare.components.tabs.settings.widgets.rpc import RPCSettings
from rare.models.options import options, LibraryView
from rare.shared import LegendaryCoreSingleton
from rare.ui.components.tabs.settings.rare import Ui_RareSettings
from rare.utils.misc import (
@ -22,116 +22,117 @@ from rare.utils.paths import create_desktop_link, desktop_link_path, log_dir, de
logger = getLogger("RareSettings")
languages = [("en", "English"),
("de", "Deutsch"),
("fr", "Français"),
("zh-Hans", "Simplified Chinese"),
("zh_TW", "Chinese Taiwan"),
("pt_BR", "Portuguese (Brazil)"),
("ca", "Catalan"),
("ru", "Russian"),
("tr", "Turkish"),
("uk", "Ukrainian")]
class RareSettings(QWidget, Ui_RareSettings):
class RareSettings(QWidget):
def __init__(self, parent=None):
super(RareSettings, self).__init__(parent=parent)
self.setupUi(self)
self.ui = Ui_RareSettings()
self.ui.setupUi(self)
self.core = LegendaryCoreSingleton()
# (widget_name, option_name, default)
self.checkboxes = [
(self.sys_tray, "sys_tray", True),
(self.auto_update, "auto_update", False),
(self.confirm_start, "confirm_start", False),
(self.auto_sync_cloud, "auto_sync_cloud", False),
(self.notification, "notification", True),
(self.save_size, "save_size", False),
(self.log_games, "show_console", False),
]
self.settings = QSettings()
language = self.settings.value("language", self.core.language_code, type=str)
self.settings = QSettings(self)
# Select lang
self.lang_select.addItems([i[1] for i in languages])
if language in get_translations():
index = [lang[0] for lang in languages].index(language)
self.lang_select.setCurrentIndex(index)
self.ui.lang_select.addItem(self.tr("System default"), options.language.default)
for lang_code, title in get_translations():
self.ui.lang_select.addItem(title, lang_code)
language = self.settings.value(*options.language)
if (index := self.ui.lang_select.findData(language, Qt.UserRole)) > 0:
self.ui.lang_select.setCurrentIndex(index)
else:
self.lang_select.setCurrentIndex(0)
self.lang_select.currentIndexChanged.connect(self.update_lang)
self.ui.lang_select.setCurrentIndex(0)
self.ui.lang_select.currentIndexChanged.connect(self.on_lang_changed)
colors = get_color_schemes()
self.color_select.addItems(colors)
if (color := self.settings.value("color_scheme")) in colors:
self.color_select.setCurrentIndex(self.color_select.findText(color))
self.color_select.setDisabled(False)
self.style_select.setDisabled(True)
self.ui.color_select.addItem(self.tr("None"), "")
for item in get_color_schemes():
self.ui.color_select.addItem(item, item)
color = self.settings.value(*options.color_scheme)
if (index := self.ui.color_select.findData(color, Qt.UserRole)) > 0:
self.ui.color_select.setCurrentIndex(index)
self.ui.color_select.setDisabled(False)
self.ui.style_select.setDisabled(True)
else:
self.color_select.setCurrentIndex(0)
self.color_select.currentIndexChanged.connect(self.on_color_select_changed)
self.ui.color_select.setCurrentIndex(0)
self.ui.color_select.currentIndexChanged.connect(self.on_color_select_changed)
styles = get_style_sheets()
self.style_select.addItems(styles)
if (style := self.settings.value("style_sheet")) in styles:
self.style_select.setCurrentIndex(self.style_select.findText(style))
self.style_select.setDisabled(False)
self.color_select.setDisabled(True)
self.ui.style_select.addItem(self.tr("None"), "")
for item in get_style_sheets():
self.ui.style_select.addItem(item, item)
style = self.settings.value(*options.style_sheet)
if (index := self.ui.style_select.findData(style, Qt.UserRole)) > 0:
self.ui.style_select.setCurrentIndex(index)
self.ui.style_select.setDisabled(False)
self.ui.color_select.setDisabled(True)
else:
self.style_select.setCurrentIndex(0)
self.style_select.currentIndexChanged.connect(self.on_style_select_changed)
self.ui.style_select.setCurrentIndex(0)
self.ui.style_select.currentIndexChanged.connect(self.on_style_select_changed)
self.ui.view_combo.addItem(self.tr("Game covers"), LibraryView.COVER)
self.ui.view_combo.addItem(self.tr("Vertical list"), LibraryView.VLIST)
view = LibraryView(self.settings.value(*options.library_view))
if (index := self.ui.view_combo.findData(view)) > -1:
self.ui.view_combo.setCurrentIndex(index)
else:
self.ui.view_combo.setCurrentIndex(0)
self.ui.view_combo.currentIndexChanged.connect(self.on_view_combo_changed)
self.rpc = RPCSettings(self)
self.right_layout.insertWidget(1, self.rpc, alignment=Qt.AlignTop)
self.ui.right_layout.insertWidget(1, self.rpc, alignment=Qt.AlignTop)
self.init_checkboxes(self.checkboxes)
self.sys_tray.stateChanged.connect(
lambda: self.settings.setValue("sys_tray", self.sys_tray.isChecked())
self.ui.sys_tray.setChecked(self.settings.value(*options.sys_tray))
self.ui.sys_tray.stateChanged.connect(
lambda: self.settings.setValue(options.sys_tray.key, self.ui.sys_tray.isChecked())
)
self.auto_update.stateChanged.connect(
lambda: self.settings.setValue("auto_update", self.auto_update.isChecked())
self.ui.auto_update.setChecked(self.settings.value(*options.auto_update))
self.ui.auto_update.stateChanged.connect(
lambda: self.settings.setValue(options.auto_update.key, self.ui.auto_update.isChecked())
)
self.confirm_start.stateChanged.connect(
lambda: self.settings.setValue(
"confirm_start", self.confirm_start.isChecked()
)
self.ui.confirm_start.setChecked(self.settings.value(*options.confirm_start))
self.ui.confirm_start.stateChanged.connect(
lambda: self.settings.setValue(options.confirm_start.key, self.ui.confirm_start.isChecked())
)
self.auto_sync_cloud.stateChanged.connect(
lambda: self.settings.setValue(
"auto_sync_cloud", self.auto_sync_cloud.isChecked()
)
self.ui.auto_sync_cloud.setChecked(self.settings.value(*options.auto_sync_cloud))
self.ui.auto_sync_cloud.stateChanged.connect(
lambda: self.settings.setValue(options.auto_sync_cloud.key, self.ui.auto_sync_cloud.isChecked())
)
self.notification.stateChanged.connect(
lambda: self.settings.setValue("notification", self.notification.isChecked())
self.ui.notification.setChecked(self.settings.value(*options.notification))
self.ui.notification.stateChanged.connect(
lambda: self.settings.setValue(options.notification.key, self.ui.notification.isChecked())
)
self.save_size.stateChanged.connect(self.save_window_size)
self.log_games.stateChanged.connect(
lambda: self.settings.setValue("show_console", self.log_games.isChecked())
self.ui.save_size.setChecked(self.settings.value(*options.save_size))
self.ui.save_size.stateChanged.connect(self.save_window_size)
self.ui.log_games.setChecked(self.settings.value(*options.log_games))
self.ui.log_games.stateChanged.connect(
lambda: self.settings.setValue(options.log_games.key, self.ui.log_games.isChecked())
)
if desktop_links_supported():
self.desktop_link = desktop_link_path("Rare", "desktop")
self.start_menu_link = desktop_link_path("Rare", "start_menu")
else:
self.desktop_link_btn.setToolTip(self.tr("Not supported"))
self.desktop_link_btn.setDisabled(True)
self.startmenu_link_btn.setToolTip(self.tr("Not supported"))
self.startmenu_link_btn.setDisabled(True)
self.ui.desktop_link_btn.setToolTip(self.tr("Not supported"))
self.ui.desktop_link_btn.setDisabled(True)
self.ui.startmenu_link_btn.setToolTip(self.tr("Not supported"))
self.ui.startmenu_link_btn.setDisabled(True)
self.desktop_link = ""
self.start_menu_link = ""
if self.desktop_link and self.desktop_link.exists():
self.desktop_link_btn.setText(self.tr("Remove desktop link"))
self.ui.desktop_link_btn.setText(self.tr("Remove desktop link"))
if self.start_menu_link and self.start_menu_link.exists():
self.startmenu_link_btn.setText(self.tr("Remove start menu link"))
self.ui.startmenu_link_btn.setText(self.tr("Remove start menu link"))
self.desktop_link_btn.clicked.connect(self.create_desktop_link)
self.startmenu_link_btn.clicked.connect(self.create_start_menu_link)
self.ui.desktop_link_btn.clicked.connect(self.create_desktop_link)
self.ui.startmenu_link_btn.clicked.connect(self.create_start_menu_link)
self.log_dir_open_button.clicked.connect(self.open_dir)
self.log_dir_clean_button.clicked.connect(self.clean_logdir)
self.ui.log_dir_open_button.clicked.connect(self.open_directory)
self.ui.log_dir_clean_button.clicked.connect(self.clean_logdir)
# get size of logdir
size = sum(
@ -139,10 +140,11 @@ class RareSettings(QWidget, Ui_RareSettings):
for f in log_dir().iterdir()
if log_dir().joinpath(f).is_file()
)
self.log_dir_size_label.setText(format_size(size))
self.ui.log_dir_size_label.setText(format_size(size))
# self.log_dir_clean_button.setVisible(False)
# self.log_dir_size_label.setVisible(False)
@pyqtSlot()
def clean_logdir(self):
for f in log_dir().iterdir():
try:
@ -155,17 +157,18 @@ class RareSettings(QWidget, Ui_RareSettings):
for f in log_dir().iterdir()
if log_dir().joinpath(f).is_file()
)
self.log_dir_size_label.setText(format_size(size))
self.ui.log_dir_size_label.setText(format_size(size))
@pyqtSlot()
def create_start_menu_link(self):
try:
if not os.path.exists(self.start_menu_link):
if not create_desktop_link(app_name="rare_shortcut", link_type="start_menu"):
return
self.startmenu_link_btn.setText(self.tr("Remove start menu link"))
self.ui.startmenu_link_btn.setText(self.tr("Remove start menu link"))
else:
os.remove(self.start_menu_link)
self.startmenu_link_btn.setText(self.tr("Create start menu link"))
self.ui.startmenu_link_btn.setText(self.tr("Create start menu link"))
except PermissionError as e:
logger.error(str(e))
QMessageBox.warning(
@ -174,15 +177,16 @@ class RareSettings(QWidget, Ui_RareSettings):
self.tr("Permission error, cannot remove {}").format(self.start_menu_link),
)
@pyqtSlot()
def create_desktop_link(self):
try:
if not os.path.exists(self.desktop_link):
if not create_desktop_link(app_name="rare_shortcut", link_type="desktop"):
return
self.desktop_link_btn.setText(self.tr("Remove Desktop link"))
self.ui.desktop_link_btn.setText(self.tr("Remove Desktop link"))
else:
os.remove(self.desktop_link)
self.desktop_link_btn.setText(self.tr("Create desktop link"))
self.ui.desktop_link_btn.setText(self.tr("Create desktop link"))
except PermissionError as e:
logger.error(str(e))
logger.warning(
@ -191,43 +195,46 @@ class RareSettings(QWidget, Ui_RareSettings):
self.tr("Permission error, cannot remove {}").format(self.start_menu_link),
)
def on_color_select_changed(self, scheme):
@pyqtSlot(int)
def on_color_select_changed(self, index: int):
scheme = self.ui.color_select.itemData(index, Qt.UserRole)
if scheme:
self.style_select.setCurrentIndex(0)
self.style_select.setDisabled(True)
self.settings.setValue("color_scheme", self.color_select.currentText())
set_color_pallete(self.color_select.currentText())
self.ui.style_select.setCurrentIndex(0)
self.ui.style_select.setDisabled(True)
else:
self.settings.setValue("color_scheme", "")
self.style_select.setDisabled(False)
set_color_pallete("")
self.ui.style_select.setDisabled(False)
self.settings.setValue("color_scheme", scheme)
set_color_pallete(scheme)
def on_style_select_changed(self, style):
@pyqtSlot(int)
def on_style_select_changed(self, index: int):
style = self.ui.style_select.itemData(index, Qt.UserRole)
if style:
self.color_select.setCurrentIndex(0)
self.color_select.setDisabled(True)
self.settings.setValue("style_sheet", self.style_select.currentText())
set_style_sheet(self.style_select.currentText())
self.ui.color_select.setCurrentIndex(0)
self.ui.color_select.setDisabled(True)
else:
self.settings.setValue("style_sheet", "")
self.color_select.setDisabled(False)
set_style_sheet("")
self.ui.color_select.setDisabled(False)
self.settings.setValue("style_sheet", style)
set_style_sheet(style)
def open_dir(self):
if platform.system() == "Windows":
os.startfile(log_dir()) # pylint: disable=E1101
else:
opener = "open" if sys.platform == "darwin" else "xdg-open"
subprocess.Popen([opener, log_dir()])
@pyqtSlot(int)
def on_view_combo_changed(self, index: int):
view = LibraryView(self.ui.view_combo.itemData(index, Qt.UserRole))
self.settings.setValue(options.library_view.key, int(view))
@pyqtSlot()
def open_directory(self):
QDesktopServices.openUrl(QUrl(f"file://{log_dir()}"))
@pyqtSlot()
def save_window_size(self):
self.settings.setValue("save_size", self.save_size.isChecked())
self.settings.remove("window_size")
self.settings.setValue(options.save_size.key, self.ui.save_size.isChecked())
self.settings.remove(options.window_size.key)
def update_lang(self, i: int):
self.settings.setValue("language", languages[i][0])
def init_checkboxes(self, checkboxes):
for cb in checkboxes:
widget, option, default = cb
widget.setChecked(self.settings.value(option, default, bool))
@pyqtSlot(int)
def on_lang_changed(self, index: int):
lang_code = self.ui.lang_select.itemData(index, Qt.UserRole)
if lang_code == locale.getlocale()[0]:
self.settings.remove(options.language.key)
else:
self.settings.setValue(options.language.key, lang_code)

View file

@ -0,0 +1,43 @@
import platform as pf
from logging import getLogger
from .widgets.env_vars import EnvVars
from .widgets.game import GameSettingsBase
from .widgets.launch import LaunchSettingsBase
from .widgets.overlay import DxvkSettings
from .widgets.wrappers import WrapperSettings
if pf.system() != "Windows":
from .widgets.wine import WineSettings
if pf.system() in {"Linux", "FreeBSD"}:
from .widgets.proton import ProtonSettings
from .widgets.overlay import MangoHudSettings
logger = getLogger("GameSettings")
class LaunchSettings(LaunchSettingsBase):
def __init__(self, parent=None):
super(LaunchSettings, self).__init__(WrapperSettings, parent=parent)
class GameSettings(GameSettingsBase):
def __init__(self, parent=None):
if pf.system() != "Windows":
if pf.system() in {"Linux", "FreeBSD"}:
super(GameSettings, self).__init__(
LaunchSettings, DxvkSettings, EnvVars,
WineSettings, ProtonSettings, MangoHudSettings,
parent=parent
)
else:
super(GameSettings, self).__init__(
LaunchSettings, DxvkSettings, EnvVars,
WineSettings,
parent=parent
)
else:
super(GameSettings, self).__init__(
LaunchSettings, DxvkSettings, EnvVars,
parent=parent
)

View file

@ -1,26 +0,0 @@
from PyQt5.QtCore import QCoreApplication
from .overlay_settings import OverlaySettings, CustomOption
class DxvkSettings(OverlaySettings):
def __init__(self):
super(DxvkSettings, self).__init__(
[
("fps", QCoreApplication.translate("DxvkSettings", "FPS")),
("frametime", QCoreApplication.translate("DxvkSettings", "Frametime")),
("memory", QCoreApplication.translate("DxvkSettings", "Memory usage")),
("gpuload", QCoreApplication.translate("DxvkSettings", "GPU usage")),
("devinfo", QCoreApplication.translate("DxvkSettings", "Show Device info")),
("version", QCoreApplication.translate("DxvkSettings", "DXVK Version")),
("api", QCoreApplication.translate("DxvkSettings", "D3D feature level")),
("compiler", QCoreApplication.translate("DxvkSettings", "Compiler activity")),
],
[
(CustomOption.number_input("scale", 1, True), QCoreApplication.translate("DxvkSettings", "Scale"))
],
"DXVK_HUD", "0"
)
self.setTitle(self.tr("DXVK Settings"))
self.gb_options.setTitle(self.tr("Custom options"))

View file

@ -1,6 +1,7 @@
from logging import getLogger
from PyQt5.QtCore import QFileSystemWatcher, Qt
from PyQt5.QtGui import QShowEvent
from PyQt5.QtWidgets import (
QGroupBox,
QHeaderView,
@ -20,6 +21,7 @@ class EnvVars(QGroupBox):
self.setTitle(self.tr("Environment variables"))
self.core = LegendaryCoreSingleton()
self.app_name: str = "default"
self.table_model = EnvVarsTableModel(self.core)
self.table_view = QTableView(self)
@ -44,8 +46,14 @@ class EnvVars(QGroupBox):
layout = QVBoxLayout(self)
layout.addWidget(self.table_view)
def keyPressEvent(self, e):
if e.key() in {Qt.Key_Delete, Qt.Key_Backspace}:
def showEvent(self, a0: QShowEvent):
if a0.spontaneous():
return super().showEvent(a0)
self.table_model.load(self.app_name)
return super().showEvent(a0)
def keyPressEvent(self, a0):
if a0.key() in {Qt.Key_Delete, Qt.Key_Backspace}:
indexes = self.table_view.selectedIndexes()
if not len(indexes):
return
@ -54,11 +62,8 @@ class EnvVars(QGroupBox):
self.table_view.model().removeRow(idx.row())
elif idx.column() == 1:
self.table_view.model().setData(idx, "", Qt.EditRole)
elif e.key() == Qt.Key_Escape:
e.ignore()
elif a0.key() == Qt.Key_Escape:
a0.ignore()
def reset_model(self):
self.table_model.reset()
def update_game(self, app_name):
self.table_model.load(app_name)

View file

@ -8,15 +8,17 @@ from PyQt5.QtCore import Qt, QModelIndex, QAbstractTableModel, pyqtSlot
from PyQt5.QtGui import QFont
from rare.lgndr.core import LegendaryCore
from rare.utils.misc import icon
from rare.utils.misc import qta_icon
if platform.system() != "Windows":
if platform.system() != "Darwin":
from rare.utils import proton
from rare.utils.compat.wine import get_wine_environment
if platform.system() in {"Linux", "FreeBSD"}:
from rare.utils.compat.steam import get_steam_environment
class EnvVarsTableModel(QAbstractTableModel):
def __init__(self, core: LegendaryCore, parent = None):
def __init__(self, core: LegendaryCore, parent=None):
super(EnvVarsTableModel, self).__init__(parent=parent)
self.core = core
@ -26,15 +28,15 @@ class EnvVarsTableModel(QAbstractTableModel):
self.__validator = re.compile(r"(^[A-Za-z_][A-Za-z0-9_]*)")
self.__data_map: ChainMap = ChainMap()
self.__readonly = [
"STEAM_COMPAT_DATA_PATH",
"WINEPREFIX",
self.__readonly = {
"DXVK_HUD",
"MANGOHUD",
"MANGOHUD_CONFIG",
]
}
if platform.system() != "Windows":
if platform.system() != "Darwin":
self.__readonly.extend(proton.get_steam_environment(None).keys())
self.__readonly.update(get_wine_environment().keys())
if platform.system() in {"Linux", "FreeBSD"}:
self.__readonly.update(get_steam_environment().keys())
self.__default: str = "default"
self.__appname: str = None
@ -137,12 +139,12 @@ class EnvVarsTableModel(QAbstractTableModel):
if orientation == Qt.Vertical:
if section < self.__data_length():
if self.__is_readonly(section) or not self.__is_local(section):
return icon("mdi.lock", "ei.lock")
return qta_icon("mdi.lock", "ei.lock")
if self.__is_global(section) and self.__is_local(section):
return icon("mdi.refresh", "ei.refresh")
return qta_icon("mdi.refresh", "ei.refresh")
if self.__is_local(section):
return icon("mdi.delete", "ei.remove-sign")
return icon("mdi.plus", "ei.plus-sign")
return qta_icon("mdi.delete", "ei.remove-sign")
return qta_icon("mdi.plus", "ei.plus-sign")
if role == Qt.TextAlignmentRole:
return Qt.AlignVCenter + Qt.AlignHCenter
return None
@ -256,8 +258,6 @@ class EnvVarsTableModel(QAbstractTableModel):
if __name__ == "__main__":
from PyQt5.QtWidgets import QApplication, QDialog, QVBoxLayout, QTableView, QHeaderView
from rare.resources import static_css
from rare.resources.stylesheets import RareStyle
from rare.utils.misc import set_style_sheet
from legendary.core import LegendaryCore

View file

@ -0,0 +1,81 @@
import platform as pf
from typing import Type
from PyQt5.QtCore import QSettings, Qt
from PyQt5.QtGui import QHideEvent
from PyQt5.QtWidgets import (
QWidget,
QVBoxLayout
)
from rare.shared import LegendaryCoreSingleton
from rare.utils import config_helper as config
from rare.widgets.side_tab import SideTabContents
from .env_vars import EnvVars
from .launch import LaunchSettingsType
from .overlay import DxvkSettings
if pf.system() != "Windows":
from .wine import WineSettings
if pf.system() in {"Linux", "FreeBSD"}:
from .proton import ProtonSettings
from .overlay import MangoHudSettings
class GameSettingsBase(QWidget, SideTabContents):
def __init__(
self,
launch_widget: Type[LaunchSettingsType],
dxvk_widget: Type[DxvkSettings],
envvar_widget: Type[EnvVars],
wine_widget: Type['WineSettings'] = None,
proton_widget: Type['ProtonSettings'] = None,
mangohud_widget: Type['MangoHudSettings'] = None,
parent=None
):
super(GameSettingsBase, self).__init__(parent=parent)
self.core = LegendaryCoreSingleton()
self.settings = QSettings(self)
self.app_name: str = "default"
self.launch = launch_widget(self)
self.env_vars = envvar_widget(self)
if pf.system() != "Windows":
self.wine = wine_widget(self)
self.wine.environ_changed.connect(self.env_vars.reset_model)
if pf.system() in {"Linux", "FreeBSD"}:
self.proton_tool = proton_widget(self)
self.proton_tool.environ_changed.connect(self.env_vars.reset_model)
self.proton_tool.tool_enabled.connect(self.wine.tool_enabled)
self.proton_tool.tool_enabled.connect(self.launch.tool_enabled)
self.dxvk = dxvk_widget(self)
self.dxvk.environ_changed.connect(self.env_vars.reset_model)
if pf.system() in {"Linux", "FreeBSD"}:
self.mangohud = mangohud_widget(self)
self.mangohud.environ_changed.connect(self.env_vars.reset_model)
self.main_layout = QVBoxLayout(self)
self.main_layout.addWidget(self.launch)
if pf.system() != "Windows":
self.main_layout.addWidget(self.wine)
if pf.system() in {"Linux", "FreeBSD"}:
self.main_layout.addWidget(self.proton_tool)
self.main_layout.addWidget(self.dxvk)
if pf.system() in {"Linux", "FreeBSD"}:
self.main_layout.addWidget(self.mangohud)
self.main_layout.addWidget(self.env_vars)
self.main_layout.setAlignment(Qt.AlignTop)
def hideEvent(self, a0: QHideEvent):
if a0.spontaneous():
return super().hideEvent(a0)
config.save_config()
return super().hideEvent(a0)

View file

@ -0,0 +1,96 @@
import os
import shlex
import shutil
from typing import Tuple, Type, TypeVar
from PyQt5.QtCore import Qt, pyqtSlot
from PyQt5.QtGui import QShowEvent
from PyQt5.QtWidgets import QCheckBox, QFileDialog, QFormLayout, QVBoxLayout, QGroupBox
from rare.shared import LegendaryCoreSingleton
import rare.utils.config_helper as config
from rare.widgets.indicator_edit import PathEdit, IndicatorReasonsCommon
from .wrappers import WrapperSettings
class LaunchSettingsBase(QGroupBox):
def __init__(
self,
wrapper_widget: Type[WrapperSettings],
parent=None
):
super(LaunchSettingsBase, self).__init__(parent=parent)
self.setTitle(self.tr("Launch settings"))
self.core = LegendaryCoreSingleton()
self.app_name: str = "default"
self.prelaunch_edit = PathEdit(
path="",
placeholder=self.tr("Path to script or program to run before the game launches"),
file_mode=QFileDialog.ExistingFile,
edit_func=self.__prelaunch_edit_callback,
save_func=self.__prelaunch_save_callback,
)
self.wrappers_widget = wrapper_widget(self)
self.prelaunch_check = QCheckBox(self.tr("Wait for command to finish before starting the game"))
font = self.font()
font.setItalic(True)
self.prelaunch_check.setFont(font)
self.prelaunch_check.stateChanged.connect(self.__prelauch_check_changed)
prelaunch_layout = QVBoxLayout()
prelaunch_layout.addWidget(self.prelaunch_edit)
prelaunch_layout.addWidget(self.prelaunch_check)
self.main_layout = QFormLayout(self)
self.main_layout.setFieldGrowthPolicy(QFormLayout.ExpandingFieldsGrow)
self.main_layout.setLabelAlignment(Qt.AlignRight | Qt.AlignVCenter)
self.main_layout.setFormAlignment(Qt.AlignLeading | Qt.AlignTop)
self.main_layout.addRow(self.tr("Wrappers"), self.wrappers_widget)
self.main_layout.addRow(self.tr("Prelaunch"), prelaunch_layout)
def showEvent(self, a0: QShowEvent):
if a0.spontaneous():
return super().showEvent(a0)
command = config.get_option(self.app_name, "pre_launch_command", fallback="")
wait = config.get_boolean(self.app_name, "pre_launch_wait", fallback=False)
self.prelaunch_edit.setText(command)
self.prelaunch_check.setChecked(wait)
self.prelaunch_check.setEnabled(bool(command))
return super().showEvent(a0)
@pyqtSlot()
def tool_enabled(self):
self.wrappers_widget.update_state()
@staticmethod
def __prelaunch_edit_callback(text: str) -> Tuple[bool, str, int]:
if not text.strip():
return True, text, IndicatorReasonsCommon.VALID
try:
command = shlex.split(text)[0]
except ValueError:
return False, text, IndicatorReasonsCommon.WRONG_FORMAT
if not os.path.isfile(command) and not shutil.which(command):
return False, text, IndicatorReasonsCommon.FILE_NOT_EXISTS
else:
return True, text, IndicatorReasonsCommon.VALID
def __prelaunch_save_callback(self, text):
config.save_option(self.app_name, "pre_launch_command", text)
self.prelaunch_check.setEnabled(bool(text))
if not text:
config.remove_option(self.app_name, "pre_launch_wait")
def __prelauch_check_changed(self):
config.set_boolean(self.app_name, "pre_launch_wait", self.prelaunch_check.isChecked())
LaunchSettingsType = TypeVar("LaunchSettingsType", bound=LaunchSettingsBase)

View file

@ -1,88 +0,0 @@
import os
import shutil
from logging import getLogger
from PyQt5.QtCore import pyqtSignal
from PyQt5.QtWidgets import QFileDialog, QWidget
from rare.components.tabs.settings.widgets.dxvk import DxvkSettings
from rare.components.tabs.settings.widgets.mangohud import MangoHudSettings
from rare.shared import LegendaryCoreSingleton, GlobalSignalsSingleton
from rare.ui.components.tabs.settings.linux import Ui_LinuxSettings
from rare.widgets.indicator_edit import PathEdit, IndicatorReasonsCommon
from rare.utils import config_helper
logger = getLogger("LinuxSettings")
class LinuxSettings(QWidget):
# str: option key
environ_changed = pyqtSignal(str)
def __init__(self, name=None, parent=None):
super(LinuxSettings, self).__init__(parent=parent)
self.ui = Ui_LinuxSettings()
self.ui.setupUi(self)
self.core = LegendaryCoreSingleton()
self.signals = GlobalSignalsSingleton()
self.name = name if name is not None else "default"
# Wine prefix
self.wine_prefix = PathEdit(
self.load_prefix(),
file_mode=QFileDialog.DirectoryOnly,
edit_func=lambda path: (os.path.isdir(path) or not path, path, IndicatorReasonsCommon.DIR_NOT_EXISTS),
save_func=self.save_prefix,
)
self.ui.prefix_layout.addWidget(self.wine_prefix)
# Wine executable
self.wine_exec = PathEdit(
self.load_setting(self.name, "wine_executable"),
file_mode=QFileDialog.ExistingFile,
name_filters=["wine", "wine64"],
edit_func=lambda text: (os.path.exists(text) or not text, text, IndicatorReasonsCommon.DIR_NOT_EXISTS),
save_func=lambda text: self.save_setting(
text, section=self.name, setting="wine_executable"
),
)
self.ui.exec_layout.addWidget(self.wine_exec)
# dxvk
self.dxvk = DxvkSettings()
self.dxvk.environ_changed.connect(self.environ_changed)
self.ui.linux_layout.addWidget(self.dxvk)
self.dxvk.load_settings(self.name)
self.mangohud = MangoHudSettings()
self.mangohud.environ_changed.connect(self.environ_changed)
self.ui.linux_layout.addWidget(self.mangohud)
self.mangohud.load_settings(self.name)
def load_prefix(self) -> str:
return self.load_setting(
f"{self.name}.env",
"WINEPREFIX",
fallback=self.load_setting(self.name, "wine_prefix"),
)
def save_prefix(self, text: str):
self.save_setting(text, f"{self.name}.env", "WINEPREFIX")
self.environ_changed.emit("WINEPREFIX")
self.save_setting(text, self.name, "wine_prefix")
self.signals.application.prefix_updated.emit()
def load_setting(self, section: str, setting: str, fallback: str = ""):
return self.core.lgd.config.get(section, setting, fallback=fallback)
def save_setting(self, text: str, section: str, setting: str):
if text:
config_helper.add_option(section, setting, text)
logger.debug(f"Set {setting} in {f'[{section}]'} to {text}")
else:
config_helper.remove_option(section, setting)
logger.debug(f"Unset {setting} from {f'[{section}]'}")
config_helper.save_config()

View file

@ -1,108 +0,0 @@
import shutil
from enum import Enum
from PyQt5.QtCore import QCoreApplication, pyqtSignal
from PyQt5.QtWidgets import QMessageBox
from rare.shared import LegendaryCoreSingleton
from .overlay_settings import OverlaySettings, CustomOption, ActivationStates
from rare.utils import config_helper
position_values = ["default", "top-left", "top-right", "middle-left", "middle-right", "bottom-left",
"bottom-right", "top-center"]
class MangoHudSettings(OverlaySettings):
set_wrapper_activated = pyqtSignal(bool)
def __init__(self):
super(MangoHudSettings, self).__init__(
[
("fps", QCoreApplication.translate("MangoSettings", "FPS")),
("frame_timing", QCoreApplication.translate("MangoSettings", "Frame Time")),
("cpu_stats", QCoreApplication.translate("MangoSettings", "CPU Load")),
("gpu_stats", QCoreApplication.translate("MangoSettings", "GPU Load")),
("cpu_temp", QCoreApplication.translate("MangoSettings", "CPU Temp")),
("gpu_temp", QCoreApplication.translate("MangoSettings", "GPU Temp")),
("ram", QCoreApplication.translate("MangoSettings", "Memory usage")),
("vram", QCoreApplication.translate("MangoSettings", "VRAM usage")),
("time", QCoreApplication.translate("MangoSettings", "Local Time")),
("version", QCoreApplication.translate("MangoSettings", "MangoHud Version")),
("arch", QCoreApplication.translate("MangoSettings", "System architecture")),
("histogram", QCoreApplication.translate("MangoSettings", "FPS Graph")),
("gpu_name", QCoreApplication.translate("MangoSettings", "GPU Name")),
("cpu_power", QCoreApplication.translate("MangoSettings", "CPU Power consumption")),
("gpu_power", QCoreApplication.translate("MangoSettings", "GPU Power consumption")),
],
[
(
CustomOption.number_input("font_size", 24, is_float=False),
QCoreApplication.translate("MangoSettings", "Font size")
),
(
CustomOption.select_input("position", position_values),
QCoreApplication.translate("MangoSettings", "Position")
)
],
"MANGOHUD_CONFIG", "no_display", set_activation_state=self.set_activation_state
)
self.core = LegendaryCoreSingleton()
self.setTitle(self.tr("MangoHud Settings"))
self.gb_options.setTitle(self.tr("Custom options"))
def load_settings(self, name: str):
self.settings_updatable = False
self.name = name
# override
cfg = self.core.lgd.config.get(f"{name}.env", "MANGOHUD_CONFIG", fallback="")
activated = "mangohud" in self.core.lgd.config.get(name, "wrapper", fallback="")
if not activated:
self.settings_updatable = False
self.gb_options.setDisabled(True)
for i, checkbox in enumerate(list(self.checkboxes.values())):
checkbox.setChecked(i < 4)
self.show_overlay_combo.setCurrentIndex(0)
self.settings_updatable = True
return
super(MangoHudSettings, self).load_settings(name)
self.settings_updatable = False
self.show_overlay_combo.setCurrentIndex(2)
self.gb_options.setDisabled(False)
for var_name, checkbox in list(self.checkboxes.items())[:4]:
checkbox.setChecked(f"{var_name}=0" not in cfg)
self.settings_updatable = True
def set_activation_state(self, state: Enum): # pylint: disable=E0202
if state in [ActivationStates.DEFAULT, ActivationStates.HIDDEN]:
self.set_wrapper_activated.emit(False)
self.gb_options.setDisabled(True)
elif state == ActivationStates.ACTIVATED:
if not shutil.which("mangohud"):
self.show_overlay_combo.setCurrentIndex(0)
QMessageBox.warning(self, "Error", self.tr("Mangohud is not installed or not in path"))
return
cfg = self.core.lgd.config.get(f"{self.name}.env", "MANGOHUD_CONFIG", fallback="")
split_config = cfg.split(",")
for name in list(self.checkboxes.keys())[:4]:
if name in split_config:
split_config.remove(name)
cfg = ",".join(split_config)
for var_name, checkbox in list(self.checkboxes.items())[:4]: # first three are by default activated
if not checkbox.isChecked():
if cfg:
cfg += f",{var_name}=0"
else:
cfg = f"{var_name}=0"
if cfg:
config_helper.add_option(f"{self.name}.env", "MANGOHUD_CONFIG", cfg)
self.environ_changed.emit(self.config_env_var_name)
else:
config_helper.remove_option(f"{self.name}.env", "MANGOHUD_CONFIG")
self.environ_changed.emit(self.config_env_var_name)
self.set_wrapper_activated.emit(True)

View file

@ -0,0 +1,337 @@
from abc import abstractmethod
from enum import IntEnum
from logging import getLogger
from typing import List, Dict, Tuple, Union, Optional
from PyQt5.QtCore import pyqtSignal, Qt
from PyQt5.QtGui import QIntValidator, QDoubleValidator, QShowEvent
from PyQt5.QtWidgets import QGroupBox, QCheckBox, QLineEdit, QComboBox
from rare.ui.components.tabs.settings.widgets.overlay import Ui_OverlaySettings
from rare.utils import config_helper as config
logger = getLogger("GameOverlays")
class OverlayLineEdit(QLineEdit):
def __init__(self, option: str, placeholder: str, parent=None):
self.option = option
super(OverlayLineEdit, self).__init__(parent=parent)
self.valueChanged = self.textChanged
self.setPlaceholderText(placeholder)
def setDefault(self):
self.setText("")
def getValue(self) -> Optional[str]:
return f"{self.option}={text}" if (text := self.text()) else None
def setValue(self, options: Dict[str, str]):
if (value := options.get(self.option, None)) is not None:
self.setText(value)
options.pop(self.option)
else:
self.setDefault()
class OverlayComboBox(QComboBox):
def __init__(self, option: str, parent=None):
self.option = option
super(OverlayComboBox, self).__init__(parent=parent)
self.valueChanged = self.currentIndexChanged
def setDefault(self):
self.setCurrentIndex(0)
def getValue(self) -> Optional[str]:
return f"{self.option}={self.currentText()}" if self.currentIndex() > 0 else None
def setValue(self, options: Dict[str, str]):
if (value := options.get(self.option, None)) is not None:
self.setCurrentText(value)
options.pop(self.option)
else:
self.setDefault()
class OverlayCheckBox(QCheckBox):
def __init__(self, option: str, title: str, desc: str = "", default_enabled: bool = False, parent=None):
self.option = option
super().__init__(title, parent=parent)
self.setChecked(default_enabled)
self.default_enabled = default_enabled
self.setToolTip(desc)
def setDefault(self):
self.setChecked(self.default_enabled)
def getValue(self) -> Optional[str]:
# lk: return the check state in case of non-default, otherwise None
checked = self.isChecked()
value = f"{self.option}={int(checked)}" if self.default_enabled else self.option
return value if checked ^ self.default_enabled else None
def setValue(self, options: Dict[str, str]):
if options.get(self.option, None) is not None:
self.setChecked(not self.default_enabled)
options.pop(self.option)
else:
self.setChecked(self.default_enabled)
class OverlayStringInput(OverlayLineEdit):
def __init__(self, option: str, placeholder: str, parent=None):
super().__init__(option, placeholder, parent=parent)
class OverlayNumberInput(OverlayLineEdit):
def __init__(self, option: str, placeholder: Union[int, float], parent=None):
super().__init__(option, str(placeholder), parent=parent)
validator = QDoubleValidator(self) if isinstance(placeholder, float) else QIntValidator(self)
self.setValidator(validator)
class OverlaySelectInput(OverlayComboBox):
def __init__(self, option: str, values: List, parent=None):
super().__init__(option, parent=parent)
# self.addItems([str(v) for v in values])
self.addItems(map(str, values))
class ActivationStates(IntEnum):
GLOBAL = -1
DISABLED = 0
DEFAULTS = 1
CUSTOM = 2
class OverlaySettings(QGroupBox):
# str: option key
environ_changed = pyqtSignal(str)
def __init__(self, parent=None):
super(OverlaySettings, self).__init__(parent=parent)
self.ui = Ui_OverlaySettings()
self.ui.setupUi(self)
self.ui.show_overlay_combo.addItem(self.tr("Global"), ActivationStates.GLOBAL)
self.ui.show_overlay_combo.addItem(self.tr("Disabled"), ActivationStates.DISABLED)
self.ui.show_overlay_combo.addItem(self.tr("Enabled (defaults)"), ActivationStates.DEFAULTS)
self.ui.show_overlay_combo.addItem(self.tr("Enabled (custom)"), ActivationStates.CUSTOM)
self.envvar: str = None
self.force_disabled: str = None
self.force_defaults: str = None
self.app_name: str = "default"
self.option_widgets: List[Union[OverlayCheckBox, OverlayLineEdit, OverlayComboBox]] = []
# self.checkboxes: Dict[str, OverlayCheckBox] = {}
# self.values: Dict[str, Union[OverlayLineEdit, OverlayComboBox]] = {}
self.ui.options_group.setTitle(self.tr("Custom options"))
self.ui.show_overlay_combo.currentIndexChanged.connect(self.update_settings)
def setupWidget(
self,
grid_map: List[OverlayCheckBox],
form_map: List[Tuple[Union[OverlayLineEdit, OverlayComboBox], str]],
envvar: str,
force_disabled: str,
force_defaults: str,
):
self.envvar = envvar
self.force_disabled = force_disabled
self.force_defaults = force_defaults
for i, widget in enumerate(grid_map):
widget.setParent(self.ui.options_group)
self.ui.options_grid.addWidget(widget, i // 4, i % 4)
# self.checkboxes[widget.option] = widget
self.option_widgets.append(widget)
widget.stateChanged.connect(self.update_settings)
for widget, label in form_map:
widget.setParent(self.ui.options_group)
self.ui.options_form.addRow(label, widget)
# self.values[widget.option] = widget
self.option_widgets.append(widget)
widget.valueChanged.connect(self.update_settings)
@abstractmethod
def update_settings_override(self, state: ActivationStates):
raise NotImplementedError
def update_settings(self):
current_state = self.ui.show_overlay_combo.currentData(Qt.UserRole)
self.ui.options_group.setEnabled(current_state == ActivationStates.CUSTOM)
if current_state == ActivationStates.GLOBAL:
# System default (don't add any env variables)
config.remove_envvar(self.app_name, self.envvar)
elif current_state == ActivationStates.DISABLED:
# hidden
config.set_envvar(self.app_name, self.envvar, self.force_disabled)
elif current_state == ActivationStates.DEFAULTS:
config.set_envvar(self.app_name, self.envvar, self.force_defaults)
elif current_state == ActivationStates.CUSTOM:
self.ui.options_group.setDisabled(False)
# custom options
options = (name for widget in self.option_widgets if (name := widget.getValue()) is not None)
config.set_envvar(self.app_name, self.envvar, ",".join(options))
self.environ_changed.emit(self.envvar)
self.update_settings_override(current_state)
def setCurrentState(self, state: ActivationStates):
self.ui.show_overlay_combo.setCurrentIndex(self.ui.show_overlay_combo.findData(state, Qt.UserRole))
self.ui.options_group.setEnabled(state == ActivationStates.CUSTOM)
def showEvent(self, a0: QShowEvent):
if a0.spontaneous():
return super().showEvent(a0)
self.ui.options_group.blockSignals(True)
config_options = config.get_envvar(self.app_name, self.envvar, fallback=None)
if config_options is None:
logger.debug("Overlay setting %s is not present", self.envvar)
self.setCurrentState(ActivationStates.GLOBAL)
elif config_options == self.force_disabled:
self.setCurrentState(ActivationStates.DISABLED)
elif config_options == self.force_defaults:
self.setCurrentState(ActivationStates.DEFAULTS)
else:
self.setCurrentState(ActivationStates.CUSTOM)
opts = {}
for o in config_options.split(","):
if "=" in o:
k, v = o.split("=")
opts[k] = v
else:
# lk: The value doesn't matter other than not being None
opts[o] = "enable"
for widget in self.option_widgets:
widget.setValue(opts)
if opts:
logger.info("Remaining options without a gui switch: %s", ",".join(opts.keys()))
self.ui.options_group.blockSignals(False)
return super().showEvent(a0)
class DxvkSettings(OverlaySettings):
def __init__(self, parent=None):
super(DxvkSettings, self).__init__(parent=parent)
self.setTitle(self.tr("DXVK settings"))
grid = [
OverlayCheckBox("fps", self.tr("FPS")),
OverlayCheckBox("frametime", self.tr("Frametime")),
OverlayCheckBox("memory", self.tr("Memory usage")),
OverlayCheckBox("gpuload", self.tr("GPU usage")),
OverlayCheckBox("devinfo", self.tr("Device info")),
OverlayCheckBox("version", self.tr("DXVK version")),
OverlayCheckBox("api", self.tr("D3D feature level")),
OverlayCheckBox("compiler", self.tr("Compiler activity")),
]
form = [(OverlayNumberInput("scale", 1.0), self.tr("Scale"))]
self.setupWidget(grid, form, "DXVK_HUD", "0", "1")
def update_settings_override(self, state: ActivationStates):
pass
mangohud_position = [
"default",
"top-left",
"top-right",
"middle-left",
"middle-right",
"bottom-left",
"bottom-right",
"top-center",
]
class MangoHudSettings(OverlaySettings):
def __init__(self, parent=None):
super(MangoHudSettings, self).__init__(parent=parent)
self.setTitle(self.tr("MangoHud settings"))
grid = [
OverlayCheckBox("read_cfg", self.tr("Read config")),
OverlayCheckBox("fps", self.tr("FPS"), default_enabled=True),
OverlayCheckBox("frame_timing", self.tr("Frame time"), default_enabled=True),
OverlayCheckBox("cpu_stats", self.tr("CPU load"), default_enabled=True),
OverlayCheckBox("gpu_stats", self.tr("GPU load"), default_enabled=True),
OverlayCheckBox("cpu_temp", self.tr("CPU temperature")),
OverlayCheckBox("gpu_temp", self.tr("GPU temperature")),
OverlayCheckBox("ram", self.tr("Memory usage")),
OverlayCheckBox("vram", self.tr("VRAM usage")),
OverlayCheckBox("time", self.tr("Local time")),
OverlayCheckBox("version", self.tr("MangoHud version")),
OverlayCheckBox("arch", self.tr("System architecture")),
OverlayCheckBox("histogram", self.tr("FPS graph")),
OverlayCheckBox("gpu_name", self.tr("GPU name")),
OverlayCheckBox("cpu_power", self.tr("CPU power consumption")),
OverlayCheckBox("gpu_power", self.tr("GPU power consumption")),
]
form = [
(OverlayNumberInput("font_size", 24), self.tr("Font size")),
(OverlaySelectInput("position", mangohud_position), self.tr("Position")),
]
self.setupWidget(grid, form, "MANGOHUD_CONFIG", "no_display", "read_cfg")
def showEvent(self, a0: QShowEvent):
if a0.spontaneous():
return super().showEvent(a0)
self.ui.options_group.blockSignals(True)
self.ui.options_group.blockSignals(False)
return super().showEvent(a0)
def update_settings_override(self, state: IntEnum): # pylint: disable=E0202
if state == ActivationStates.GLOBAL:
config.remove_envvar(self.app_name, "MANGOHUD")
elif state == ActivationStates.DISABLED:
config.set_envvar(self.app_name, "MANGOHUD", "0")
elif state == ActivationStates.DEFAULTS:
config.set_envvar(self.app_name, "MANGOHUD", "1")
elif state == ActivationStates.CUSTOM:
config.set_envvar(self.app_name, "MANGOHUD", "1")
self.environ_changed.emit("MANGOHUD")
if __name__ == "__main__":
import sys
from PyQt5.QtWidgets import QApplication, QDialog, QVBoxLayout
from legendary.core import LegendaryCore
core = LegendaryCore()
config.init_config_handler(core)
app = QApplication(sys.argv)
dlg = QDialog()
dxvk = DxvkSettings(dlg)
mangohud = MangoHudSettings(dlg)
layout = QVBoxLayout(dlg)
layout.addWidget(dxvk)
layout.addWidget(mangohud)
dlg.show()
ret = app.exec()
config.save_config()
sys.exit(ret)

View file

@ -1,190 +0,0 @@
from enum import Enum
from logging import getLogger
from typing import List, Dict, Tuple, Any, Callable
from PyQt5.QtCore import pyqtSignal
from PyQt5.QtGui import QIntValidator, QDoubleValidator
from PyQt5.QtWidgets import QGroupBox, QCheckBox, QWidget, QLineEdit, QLabel, QComboBox
from rare.shared import LegendaryCoreSingleton
from rare.ui.components.tabs.settings.widgets.overlay import Ui_OverlaySettings
from rare.utils import config_helper
logger = getLogger("GameOverlays")
class TextInputField(QLineEdit):
def __init__(self):
super(TextInputField, self).__init__()
self.value_changed = self.textChanged
self.set_value = self.setText
self.set_default = lambda: self.setText("")
def get_value(self):
return self.text()
class ComboBox(QComboBox):
def __init__(self):
super(ComboBox, self).__init__()
self.value_changed = self.currentIndexChanged
self.get_value = self.currentText
self.set_value = self.setCurrentText
self.set_default = lambda: self.setCurrentIndex(0)
class CustomOption:
input_field: QWidget
var_name: str
@classmethod
def string_input(cls, var_name: str, placeholder: str):
tmp = cls()
tmp.input_field = TextInputField()
tmp.var_name = var_name
tmp.input_field.setPlaceholderText(placeholder)
return tmp
@classmethod
def number_input(cls, var_name: str, placeholder: Any, is_float: bool = False):
tmp = cls()
tmp.input_field = TextInputField()
tmp.var_name = var_name
tmp.input_field.setPlaceholderText(str(placeholder))
if is_float:
validator = QDoubleValidator()
else:
validator = QIntValidator()
tmp.input_field.setValidator(validator)
return tmp
@classmethod
def select_input(cls, var_name: str, options: List[str]):
"""options: default value in options[0]"""
tmp = cls()
tmp.input_field = ComboBox()
tmp.var_name = var_name
tmp.input_field.addItems(options)
return tmp
class ActivationStates(Enum):
DEFAULT = 0
HIDDEN = 1
ACTIVATED = 2
class OverlaySettings(QGroupBox, Ui_OverlaySettings):
# str: option key
environ_changed = pyqtSignal(str)
name: str = "default"
settings_updatable = True
def __init__(self, checkboxes_map: List[Tuple[str, str]], value_map: List[Tuple[CustomOption, str]],
config_env_var_name: str, no_display_value: str,
set_activation_state: Callable[[Enum], None] = lambda x: None):
super(OverlaySettings, self).__init__()
self.setupUi(self)
self.core = LegendaryCoreSingleton()
self.config_env_var_name = config_env_var_name
self.no_display_value = no_display_value
self.set_activation_state = set_activation_state
self.checkboxes: Dict[str, QCheckBox] = {}
for i, (var_name, translated_text) in enumerate(checkboxes_map):
cb = QCheckBox(translated_text)
self.options_grid.addWidget(cb, i // 4, i % 4)
self.checkboxes[var_name] = cb
cb.stateChanged.connect(self.update_settings)
self.values: Dict[str, QWidget] = {}
num_rows = len(checkboxes_map) // 4
for custom_option, translated_text in value_map:
input_field = custom_option.input_field
self.options_form.addRow(QLabel(translated_text), input_field)
self.values[custom_option.var_name] = input_field
input_field.value_changed.connect(self.update_settings)
num_rows += 1
self.show_overlay_combo.currentIndexChanged.connect(self.update_settings)
def update_settings(self):
if not self.settings_updatable:
return
if self.show_overlay_combo.currentIndex() == 0:
# System default
config_helper.remove_option(f"{self.name}.env", self.config_env_var_name)
self.environ_changed.emit(self.config_env_var_name)
self.gb_options.setDisabled(True)
self.set_activation_state(ActivationStates.DEFAULT)
return
elif self.show_overlay_combo.currentIndex() == 1:
# hidden
config_helper.add_option(f"{self.name}.env", self.config_env_var_name, self.no_display_value)
self.environ_changed.emit(self.config_env_var_name)
self.gb_options.setDisabled(True)
self.set_activation_state(ActivationStates.HIDDEN)
return
elif self.show_overlay_combo.currentIndex() == 2:
self.gb_options.setDisabled(False)
# custom options
var_names = []
for var_name, cb in self.checkboxes.items():
if cb.isChecked():
var_names.append(var_name)
for var_name, input_field in self.values.items():
text = input_field.get_value()
if text not in ["default", ""]:
var_names.append(f"{var_name}={text}")
if not var_names:
list(self.checkboxes.values())[0].setChecked(True)
var_names.append(list(self.checkboxes.keys())[0])
config_helper.add_option(f"{self.name}.env", self.config_env_var_name, ",".join(var_names))
self.environ_changed.emit(self.config_env_var_name)
self.set_activation_state(ActivationStates.ACTIVATED)
def load_settings(self, name: str):
self.settings_updatable = False
# load game specific
self.name = name
for checkbox in self.checkboxes.values():
checkbox.setChecked(False)
for input_field in self.values.values():
input_field.set_default()
options = self.core.lgd.config.get(f"{self.name}.env", self.config_env_var_name, fallback=None)
if options is None:
logger.debug(f"No Overlay settings found {self.config_env_var_name}")
self.show_overlay_combo.setCurrentIndex(0)
self.gb_options.setDisabled(True)
elif options == self.no_display_value:
# not visible
self.gb_options.setDisabled(True)
self.show_overlay_combo.setCurrentIndex(1)
else:
self.show_overlay_combo.setCurrentIndex(2)
for option in options.split(","):
try:
if "=" in option:
var_name, value = option.split("=")
if var_name in self.checkboxes.keys():
self.checkboxes[var_name].setChecked(False)
else:
self.values[var_name].set_value(value)
else:
self.checkboxes[option].setChecked(True)
except Exception as e:
logger.warning(e)
self.gb_options.setDisabled(False)
self.settings_updatable = True

View file

@ -1,61 +0,0 @@
import os
import shutil
from typing import Tuple
from PyQt5.QtWidgets import QHBoxLayout, QCheckBox, QFileDialog
from rare.shared import LegendaryCoreSingleton
from rare.utils import config_helper
from rare.widgets.indicator_edit import PathEdit, IndicatorReasonsCommon
class PreLaunchSettings(QHBoxLayout):
app_name: str
def __init__(self):
super(PreLaunchSettings, self).__init__()
self.core = LegendaryCoreSingleton()
self.edit = PathEdit(
path="",
placeholder=self.tr("Path to script"),
file_mode=QFileDialog.ExistingFile,
edit_func=self.edit_command,
save_func=self.save_pre_launch_command,
)
self.layout().addWidget(self.edit)
self.wait_check = QCheckBox(self.tr("Wait for finish"))
self.layout().addWidget(self.wait_check)
self.wait_check.stateChanged.connect(self.save_wait_finish)
def edit_command(self, text: str) -> Tuple[bool, str, int]:
if not text.strip():
return True, text, IndicatorReasonsCommon.VALID
if not os.path.isfile(text.split()[0]) and not shutil.which(text.split()[0]):
return False, text, IndicatorReasonsCommon.FILE_NOT_EXISTS
else:
return True, text, IndicatorReasonsCommon.VALID
def save_pre_launch_command(self, text):
if text:
config_helper.add_option(self.app_name, "pre_launch_command", text)
self.wait_check.setDisabled(False)
else:
config_helper.remove_option(self.app_name, "pre_launch_command")
self.wait_check.setDisabled(True)
config_helper.remove_option(self.app_name, "pre_launch_wait")
def save_wait_finish(self):
config_helper.add_option(self.app_name, "pre_launch_wait", str(self.wait_check.isChecked()).lower())
def load_settings(self, app_name):
self.app_name = app_name
command = self.core.lgd.config.get(app_name, "pre_launch_command", fallback="")
self.edit.setText(command)
wait = self.core.lgd.config.getboolean(app_name, "pre_launch_wait", fallback=False)
self.wait_check.setChecked(wait)
self.wait_check.setEnabled(bool(command))

View file

@ -1,85 +1,122 @@
import os
from logging import getLogger
from pathlib import Path
from typing import Tuple
from typing import Tuple, Union, Optional
from PyQt5.QtCore import pyqtSignal
from PyQt5.QtWidgets import QGroupBox, QFileDialog
from PyQt5.QtCore import pyqtSignal, Qt
from PyQt5.QtGui import QShowEvent
from PyQt5.QtWidgets import QGroupBox, QFileDialog, QFormLayout, QComboBox
from rare.components.tabs.settings import LinuxSettings
from rare.shared import LegendaryCoreSingleton
from rare.ui.components.tabs.settings.proton import Ui_ProtonSettings
from rare.utils import config_helper, proton
from rare.models.wrapper import Wrapper, WrapperType
from rare.shared import RareCore
from rare.shared.wrappers import Wrappers
from rare.utils import config_helper as config
from rare.utils.compat import steam
from rare.utils.paths import proton_compat_dir
from rare.widgets.indicator_edit import PathEdit, IndicatorReasonsCommon
from .wrapper import WrapperSettings
logger = getLogger("Proton")
logger = getLogger("ProtonSettings")
class ProtonSettings(QGroupBox):
# str: option key
environ_changed = pyqtSignal(str)
app_name: str
changeable = True
environ_changed: pyqtSignal = pyqtSignal(str)
# bool: state
tool_enabled: pyqtSignal = pyqtSignal(bool)
def __init__(self, linux_settings: LinuxSettings, wrapper_settings: WrapperSettings):
super(ProtonSettings, self).__init__()
self.ui = Ui_ProtonSettings()
self.ui.setupUi(self)
self._linux_settings = linux_settings
self._wrapper_settings = wrapper_settings
self.core = LegendaryCoreSingleton()
self.possible_proton_combos = proton.find_proton_combos()
def __init__(self, parent=None):
super(ProtonSettings, self).__init__(parent=parent)
self.setTitle(self.tr("Proton settings"))
self.ui.proton_combo.addItems(self.possible_proton_combos)
self.ui.proton_combo.currentIndexChanged.connect(self.change_proton)
self.tool_combo = QComboBox(self)
self.tool_combo.currentIndexChanged.connect(self.__on_proton_changed)
self.proton_prefix = PathEdit(
self.tool_prefix = PathEdit(
file_mode=QFileDialog.DirectoryOnly,
edit_func=self.proton_prefix_edit,
save_func=self.proton_prefix_save,
placeholder=self.tr("Please select path for proton prefix")
placeholder=self.tr("Please select path for proton prefix"),
parent=self
)
self.ui.prefix_layout.addWidget(self.proton_prefix)
def change_proton(self, i):
if not self.changeable:
return
# First combo box entry: Don't use Proton
if i == 0:
self._wrapper_settings.delete_wrapper("proton")
config_helper.remove_option(self.app_name, "no_wine")
config_helper.remove_option(f"{self.app_name}.env", "STEAM_COMPAT_DATA_PATH")
self.environ_changed.emit("STEAM_COMPAT_DATA_PATH")
config_helper.remove_option(f"{self.app_name}.env", "STEAM_COMPAT_CLIENT_INSTALL_PATH")
self.environ_changed.emit("STEAM_COMPAT_CLIENT_INSTALL_PATH")
layout = QFormLayout(self)
layout.addRow(self.tr("Proton tool"), self.tool_combo)
layout.addRow(self.tr("Compat data"), self.tool_prefix)
layout.setFieldGrowthPolicy(QFormLayout.ExpandingFieldsGrow)
layout.setLabelAlignment(Qt.AlignRight | Qt.AlignVCenter)
layout.setFormAlignment(Qt.AlignLeading | Qt.AlignTop)
self.proton_prefix.setEnabled(False)
self.proton_prefix.setText("")
self.app_name: str = "default"
self.core = RareCore.instance().core()
self.wrappers: Wrappers = RareCore.instance().wrappers()
self.tool_wrapper: Optional[Wrapper] = None
self._linux_settings.ui.wine_groupbox.setEnabled(True)
else:
self.proton_prefix.setEnabled(True)
self._linux_settings.ui.wine_groupbox.setEnabled(False)
wrapper = self.possible_proton_combos[i - 1]
self._wrapper_settings.add_wrapper(wrapper)
config_helper.add_option(self.app_name, "no_wine", "true")
config_helper.add_option(
f"{self.app_name}.env",
"STEAM_COMPAT_CLIENT_INSTALL_PATH",
str(Path.home().joinpath(".steam", "steam"))
def showEvent(self, a0: QShowEvent) -> None:
if a0.spontaneous():
return super().showEvent(a0)
self.tool_combo.blockSignals(True)
self.tool_combo.clear()
self.tool_combo.addItem(self.tr("Don't use a compatibility tool"), None)
tools = steam.find_tools()
for tool in tools:
self.tool_combo.addItem(tool.name, tool)
try:
wrapper = next(
filter(lambda w: w.is_compat_tool, self.wrappers.get_game_wrapper_list(self.app_name))
)
self.environ_changed.emit("STEAM_COMPAT_CLIENT_INSTALL_PATH")
self.tool_wrapper = wrapper
tool = next(filter(lambda t: t.checksum == wrapper.checksum, tools))
index = self.tool_combo.findData(tool)
except StopIteration:
index = 0
self.tool_combo.setCurrentIndex(index)
self.tool_combo.blockSignals(False)
self.proton_prefix.setText(os.path.expanduser("~/.proton"))
enabled = bool(self.tool_combo.currentData(Qt.UserRole))
self.tool_prefix.blockSignals(True)
self.tool_prefix.setText(config.get_proton_compatdata(self.app_name, fallback=""))
self.tool_prefix.setEnabled(enabled)
self.tool_prefix.blockSignals(False)
# Don't use Wine
self._linux_settings.wine_exec.setText("")
self._linux_settings.wine_prefix.setText("")
super().showEvent(a0)
config_helper.save_config()
def __on_proton_changed(self, index):
steam_tool: Union[steam.ProtonTool, steam.CompatibilityTool] = self.tool_combo.itemData(index)
def proton_prefix_edit(self, text: str) -> Tuple[bool, str, int]:
steam_environ = steam.get_steam_environment(steam_tool, self.tool_prefix.text())
for key, value in steam_environ.items():
config.save_envvar(self.app_name, key, value)
self.environ_changed.emit(key)
wrappers = self.wrappers.get_game_wrapper_list(self.app_name)
if self.tool_wrapper and self.tool_wrapper in wrappers:
wrappers.remove(self.tool_wrapper)
if steam_tool is None:
self.tool_wrapper = None
else:
wrapper = Wrapper(
command=steam_tool.command(), name=steam_tool.name, wtype=WrapperType.COMPAT_TOOL
)
wrappers.append(wrapper)
self.tool_wrapper = wrapper
self.wrappers.set_game_wrapper_list(self.app_name, wrappers)
self.tool_prefix.setEnabled(steam_tool is not None)
if steam_tool:
if not (compatdata_path := config.get_proton_compatdata(self.app_name, fallback="")):
compatdata_path = proton_compat_dir(self.app_name)
config.save_proton_compatdata(self.app_name, str(compatdata_path))
target = compatdata_path.joinpath("pfx")
if not target.is_dir():
os.makedirs(target, exist_ok=True)
self.tool_prefix.setText(str(compatdata_path))
else:
self.tool_prefix.setText("")
self.tool_enabled.emit(steam_tool is not None)
@staticmethod
def proton_prefix_edit(text: str) -> Tuple[bool, str, int]:
if not text:
return False, text, IndicatorReasonsCommon.EMPTY
parent_dir = os.path.dirname(text)
@ -88,28 +125,6 @@ class ProtonSettings(QGroupBox):
def proton_prefix_save(self, text: str):
if not text:
return
config_helper.add_option(
f"{self.app_name}.env", "STEAM_COMPAT_DATA_PATH", text
)
config.save_proton_compatdata(self.app_name, text)
self.environ_changed.emit("STEAM_COMPAT_DATA_PATH")
config_helper.save_config()
def load_settings(self, app_name: str, proton: str):
self.changeable = False
self.app_name = app_name
proton = proton.replace('"', "")
self.proton_prefix.setEnabled(bool(proton))
if proton:
self.ui.proton_combo.setCurrentText(
f'"{proton.replace(" run", "")}" run'
)
else:
self.ui.proton_combo.setCurrentIndex(0)
proton_prefix = self.core.lgd.config.get(
f"{app_name}.env",
"STEAM_COMPAT_DATA_PATH",
fallback="",
)
self.proton_prefix.setText(proton_prefix)
self.changeable = True

View file

@ -2,33 +2,35 @@ from PyQt5.QtCore import QSettings
from PyQt5.QtWidgets import QGroupBox
from rare.shared import GlobalSignalsSingleton
from rare.models.options import options
from rare.ui.components.tabs.settings.widgets.rpc import Ui_RPCSettings
class RPCSettings(QGroupBox, Ui_RPCSettings):
class RPCSettings(QGroupBox):
def __init__(self, parent):
super(RPCSettings, self).__init__(parent=parent)
self.setupUi(self)
self.ui = Ui_RPCSettings()
self.ui.setupUi(self)
self.signals = GlobalSignalsSingleton()
self.settings = QSettings()
self.enable.setCurrentIndex(self.settings.value("rpc_enable", 0, int))
self.enable.currentIndexChanged.connect(self.changed)
self.ui.enable.setCurrentIndex(self.settings.value(*options.rpc_enable))
self.ui.enable.currentIndexChanged.connect(self.__enable_changed)
self.show_game.setChecked((self.settings.value("rpc_name", True, bool)))
self.show_game.stateChanged.connect(
lambda: self.settings.setValue("rpc_game", self.show_game.isChecked())
self.ui.show_game.setChecked((self.settings.value(*options.rpc_name)))
self.ui.show_game.stateChanged.connect(
lambda: self.settings.setValue(options.rpc_name.key, self.ui.show_game.isChecked())
)
self.show_os.setChecked((self.settings.value("rpc_os", True, bool)))
self.show_os.stateChanged.connect(
lambda: self.settings.setValue("rpc_os", self.show_os.isChecked())
self.ui.show_os.setChecked((self.settings.value(*options.rpc_os)))
self.ui.show_os.stateChanged.connect(
lambda: self.settings.setValue(options.rpc_os.key, self.ui.show_os.isChecked())
)
self.show_time.setChecked((self.settings.value("rpc_time", True, bool)))
self.show_time.stateChanged.connect(
lambda: self.settings.setValue("rpc_time", self.show_time.isChecked())
self.ui.show_time.setChecked((self.settings.value(*options.rpc_time)))
self.ui.show_time.stateChanged.connect(
lambda: self.settings.setValue(options.rpc_time.key, self.ui.show_time.isChecked())
)
try:
@ -37,6 +39,6 @@ class RPCSettings(QGroupBox, Ui_RPCSettings):
self.setDisabled(True)
self.setToolTip(self.tr("Pypresence is not installed"))
def changed(self, i):
self.settings.setValue("rpc_enable", i)
def __enable_changed(self, i):
self.settings.setValue(options.rpc_enable.key, i)
self.signals.discord_rpc.apply_settings.emit()

View file

@ -0,0 +1,91 @@
import os
from logging import getLogger
from typing import Optional
from PyQt5.QtCore import pyqtSignal, Qt, QSignalBlocker
from PyQt5.QtGui import QShowEvent
from PyQt5.QtWidgets import QFileDialog, QFormLayout, QGroupBox
from rare.shared import LegendaryCoreSingleton, GlobalSignalsSingleton
from rare.utils import config_helper as config
from rare.widgets.indicator_edit import PathEdit, IndicatorReasonsCommon
logger = getLogger("WineSettings")
class WineSettings(QGroupBox):
# str: option key
environ_changed = pyqtSignal(str)
def __init__(self, parent=None):
super(WineSettings, self).__init__(parent=parent)
self.setTitle(self.tr("Wine settings"))
self.core = LegendaryCoreSingleton()
self.signals = GlobalSignalsSingleton()
self.app_name: Optional[str] = "default"
# Wine prefix
self.wine_prefix = PathEdit(
path="",
file_mode=QFileDialog.DirectoryOnly,
edit_func=lambda path: (os.path.isdir(path) or not path, path, IndicatorReasonsCommon.DIR_NOT_EXISTS),
save_func=self.save_prefix,
)
# Wine executable
self.wine_exec = PathEdit(
path="",
file_mode=QFileDialog.ExistingFile,
name_filters=["wine", "wine64"],
edit_func=lambda text: (os.path.exists(text) or not text, text, IndicatorReasonsCommon.DIR_NOT_EXISTS),
save_func=self.save_exec,
)
layout = QFormLayout(self)
layout.addRow(self.tr("Executable"), self.wine_exec)
layout.addRow(self.tr("Prefix"), self.wine_prefix)
layout.setFieldGrowthPolicy(QFormLayout.ExpandingFieldsGrow)
layout.setLabelAlignment(Qt.AlignRight | Qt.AlignVCenter)
layout.setFormAlignment(Qt.AlignLeading | Qt.AlignTop)
def showEvent(self, a0: QShowEvent):
if a0.spontaneous():
return super().showEvent(a0)
_ = QSignalBlocker(self.wine_prefix)
self.wine_prefix.setText(self.load_prefix())
_ = QSignalBlocker(self.wine_exec)
self.wine_exec.setText(self.load_exec())
self.setDisabled(config.get_boolean(self.app_name, "no_wine", fallback=False))
return super().showEvent(a0)
def tool_enabled(self, enabled: bool):
if enabled:
config.set_boolean(self.app_name, "no_wine", True)
else:
config.remove_option(self.app_name, "no_wine")
self.setDisabled(enabled)
def load_prefix(self) -> str:
if self.app_name is None:
raise RuntimeError
return config.get_wine_prefix(self.app_name, "")
def save_prefix(self, path: str) -> None:
if self.app_name is None:
raise RuntimeError
config.save_wine_prefix(self.app_name, path)
self.environ_changed.emit("WINEPREFIX")
def load_exec(self) -> str:
if self.app_name is None:
raise RuntimeError
return config.get_option(self.app_name, "wine_executable", "")
def save_exec(self, text: str) -> None:
if self.app_name is None:
raise RuntimeError
config.save_option(self.app_name, "wine_executable", text)

View file

@ -1,356 +0,0 @@
import re
import shutil
from logging import getLogger
from typing import Dict, Optional
from PyQt5.QtCore import pyqtSignal, QSettings, QSize, Qt, QMimeData, pyqtSlot, QCoreApplication
from PyQt5.QtGui import QDrag, QDropEvent, QDragEnterEvent, QDragMoveEvent, QFont, QMouseEvent
from PyQt5.QtWidgets import (
QHBoxLayout,
QLabel,
QInputDialog,
QFrame,
QMessageBox,
QSizePolicy,
QWidget,
QScrollArea,
QAction,
QToolButton,
QMenu,
)
from rare.shared import RareCore
from rare.ui.components.tabs.settings.widgets.wrapper import Ui_WrapperSettings
from rare.utils import config_helper
from rare.utils.misc import icon
logger = getLogger("WrapperSettings")
extra_wrapper_regex = {
"proton": "\".*proton\" run", # proton
"mangohud": "mangohud" # mangohud
}
class Wrapper:
pass
class WrapperWidget(QFrame):
update_wrapper = pyqtSignal(str, str)
delete_wrapper = pyqtSignal(str)
def __init__(self, text: str, show_text=None, parent=None):
super(WrapperWidget, self).__init__(parent=parent)
if not show_text:
show_text = text.split()[0]
self.setFrameShape(QFrame.StyledPanel)
self.setSizePolicy(QSizePolicy.Maximum, QSizePolicy.Fixed)
self.text = text
self.setToolTip(text)
unmanaged = show_text in extra_wrapper_regex.keys()
text_lbl = QLabel(show_text, parent=self)
text_lbl.setFont(QFont("monospace"))
text_lbl.setDisabled(unmanaged)
image_lbl = QLabel(parent=self)
image_lbl.setPixmap(icon("mdi.drag-vertical").pixmap(QSize(20, 20)))
edit_action = QAction("Edit", parent=self)
edit_action.triggered.connect(self.__edit)
delete_action = QAction("Delete", parent=self)
delete_action.triggered.connect(self.__delete)
manage_menu = QMenu(parent=self)
manage_menu.addActions([edit_action, delete_action])
manage_button = QToolButton(parent=self)
manage_button.setIcon(icon("mdi.menu"))
manage_button.setMenu(manage_menu)
manage_button.setPopupMode(QToolButton.InstantPopup)
manage_button.setDisabled(unmanaged)
if unmanaged:
manage_button.setToolTip(self.tr("Manage through settings"))
else:
manage_button.setToolTip(self.tr("Manage"))
layout = QHBoxLayout(self)
layout.setContentsMargins(0, 0, 0, 0)
layout.addWidget(image_lbl)
layout.addWidget(text_lbl)
layout.addWidget(manage_button)
self.setLayout(layout)
# lk: set object names for the stylesheet
self.setObjectName(type(self).__name__)
manage_button.setObjectName(f"{self.objectName()}Button")
@pyqtSlot()
def __delete(self):
self.delete_wrapper.emit(self.text)
def __edit(self) -> None:
dialog = QInputDialog(self)
dialog.setWindowTitle(f"{self.tr('Edit wrapper')} - {QCoreApplication.instance().applicationName()}")
dialog.setLabelText(self.tr("Edit wrapper command"))
dialog.setTextValue(self.text)
accepted = dialog.exec()
wrapper = dialog.textValue()
dialog.deleteLater()
if accepted and wrapper:
self.update_wrapper.emit(self.text, wrapper)
def mouseMoveEvent(self, a0: QMouseEvent) -> None:
if a0.buttons() == Qt.LeftButton:
a0.accept()
drag = QDrag(self)
mime = QMimeData()
drag.setMimeData(mime)
drag.exec_(Qt.MoveAction)
class WrapperSettings(QWidget):
def __init__(self):
super(WrapperSettings, self).__init__()
self.ui = Ui_WrapperSettings()
self.ui.setupUi(self)
self.wrappers: Dict[str, WrapperWidget] = {}
self.app_name: str = "default"
self.wrapper_scroll = QScrollArea(self.ui.widget_stack)
self.wrapper_scroll.setWidgetResizable(True)
self.wrapper_scroll.setSizeAdjustPolicy(QScrollArea.AdjustToContents)
self.wrapper_scroll.setVerticalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
self.wrapper_scroll.setProperty("no_kinetic_scroll", True)
self.scroll_content = WrapperContainer(
save_cb=self.save, parent=self.wrapper_scroll
)
self.wrapper_scroll.setWidget(self.scroll_content)
self.ui.widget_stack.insertWidget(0, self.wrapper_scroll)
self.core = RareCore.instance().core()
self.ui.add_button.clicked.connect(self.add_button_pressed)
self.settings = QSettings()
self.wrapper_scroll.horizontalScrollBar().rangeChanged.connect(self.adjust_scrollarea)
# lk: set object names for the stylesheet
self.setObjectName(type(self).__name__)
self.ui.no_wrapper_label.setObjectName(f"{self.objectName()}Label")
self.wrapper_scroll.setObjectName(f"{self.objectName()}Scroll")
self.wrapper_scroll.horizontalScrollBar().setObjectName(
f"{self.wrapper_scroll.objectName()}Bar")
self.wrapper_scroll.verticalScrollBar().setObjectName(
f"{self.wrapper_scroll.objectName()}Bar")
@pyqtSlot(int, int)
def adjust_scrollarea(self, min: int, max: int):
wrapper_widget = self.scroll_content.findChild(WrapperWidget)
if not wrapper_widget:
return
# lk: when the scrollbar is not visible, min and max are 0
if max > min:
self.wrapper_scroll.setMaximumHeight(
wrapper_widget.sizeHint().height()
+ self.wrapper_scroll.rect().height() // 2
- self.wrapper_scroll.contentsRect().height() // 2
+ self.scroll_content.layout().spacing()
+ self.wrapper_scroll.horizontalScrollBar().sizeHint().height()
)
else:
self.wrapper_scroll.setMaximumHeight(
wrapper_widget.sizeHint().height()
+ self.wrapper_scroll.rect().height()
- self.wrapper_scroll.contentsRect().height()
)
def get_wrapper_string(self):
return " ".join(self.get_wrapper_list())
def get_wrapper_list(self):
wrappers = list(self.wrappers.values())
wrappers.sort(key=lambda x: self.scroll_content.layout().indexOf(x))
return [w.text for w in wrappers]
def add_button_pressed(self):
dialog = QInputDialog(self)
dialog.setWindowTitle(f"{self.tr('Add wrapper')} - {QCoreApplication.instance().applicationName()}")
dialog.setLabelText(self.tr("Enter wrapper command"))
accepted = dialog.exec()
wrapper = dialog.textValue()
dialog.deleteLater()
if accepted:
self.add_wrapper(wrapper)
def add_wrapper(self, text: str, position: int = -1, from_load: bool = False):
if text == "mangohud" and self.wrappers.get("mangohud"):
return
show_text = ""
for key, extra_wrapper in extra_wrapper_regex.items():
if re.match(extra_wrapper, text):
show_text = key
if not show_text:
show_text = text.split()[0]
# validate
if not text.strip(): # is empty
return
if not from_load:
if self.wrappers.get(text):
QMessageBox.warning(
self, self.tr("Warning"), self.tr("Wrapper <b>{0}</b> is already in the list").format(text)
)
return
if show_text != "proton" and not shutil.which(text.split()[0]):
if (
QMessageBox.question(
self,
self.tr("Warning"),
self.tr("Wrapper <b>{0}</b> is not in $PATH. Add it anyway?").format(show_text),
QMessageBox.Yes | QMessageBox.No,
QMessageBox.No,
)
== QMessageBox.No
):
return
if text == "proton":
QMessageBox.warning(
self,
self.tr("Warning"),
self.tr("Do not insert <b>proton</b> manually. Add it through Proton settings"),
)
return
self.ui.widget_stack.setCurrentIndex(0)
if widget := self.wrappers.get(show_text, None):
widget.deleteLater()
widget = WrapperWidget(text, show_text, self.scroll_content)
if position < 0:
self.scroll_content.layout().addWidget(widget)
else:
self.scroll_content.layout().insertWidget(position, widget)
self.adjust_scrollarea(
self.wrapper_scroll.horizontalScrollBar().minimum(),
self.wrapper_scroll.horizontalScrollBar().maximum(),
)
widget.update_wrapper.connect(self.update_wrapper)
widget.delete_wrapper.connect(self.delete_wrapper)
self.wrappers[show_text] = widget
if not from_load:
self.save()
@pyqtSlot(str)
def delete_wrapper(self, text: str):
text = text.split()[0]
widget = self.wrappers.get(text, None)
if widget:
self.wrappers.pop(text)
widget.deleteLater()
if not self.wrappers:
self.wrapper_scroll.setMaximumHeight(self.ui.label_page.sizeHint().height())
self.ui.widget_stack.setCurrentIndex(1)
self.save()
@pyqtSlot(str, str)
def update_wrapper(self, old: str, new: str):
key = old.split()[0]
idx = self.scroll_content.layout().indexOf(self.wrappers[key])
self.delete_wrapper(key)
self.add_wrapper(new, position=idx)
def save(self):
# save wrappers twice, to support wrappers with spaces
if len(self.wrappers) == 0:
config_helper.remove_option(self.app_name, "wrapper")
self.settings.remove(f"{self.app_name}/wrapper")
else:
config_helper.add_option(self.app_name, "wrapper", self.get_wrapper_string())
self.settings.setValue(f"{self.app_name}/wrapper", self.get_wrapper_list())
def load_settings(self, app_name: str):
self.app_name = app_name
for i in self.wrappers.values():
i.deleteLater()
self.wrappers.clear()
wrappers = self.settings.value(f"{self.app_name}/wrapper", [], str)
if not wrappers and (cfg := self.core.lgd.config.get(self.app_name, "wrapper", fallback="")):
logger.info("Loading wrappers from legendary config")
# no qt wrapper, but legendary wrapper, to have backward compatibility
pattern = re.compile(r'''((?:[^ "']|"[^"]*"|'[^']*')+)''')
wrappers = pattern.split(cfg)[1::2]
for wrapper in wrappers:
self.add_wrapper(wrapper, from_load=True)
if not self.wrappers:
self.wrapper_scroll.setMaximumHeight(self.ui.label_page.sizeHint().height())
self.ui.widget_stack.setCurrentIndex(1)
else:
self.ui.widget_stack.setCurrentIndex(0)
self.save()
class WrapperContainer(QWidget):
def __init__(self, save_cb, parent=None):
super(WrapperContainer, self).__init__(parent=parent)
self.setAcceptDrops(True)
self.save = save_cb
layout = QHBoxLayout()
layout.setContentsMargins(0, 0, 0, 0)
layout.setAlignment(Qt.AlignLeft | Qt.AlignTop)
self.setLayout(layout)
self.drag_widget: Optional[QWidget] = None
# lk: set object names for the stylesheet
self.setObjectName(type(self).__name__)
def dragEnterEvent(self, e: QDragEnterEvent):
widget = e.source()
self.drag_widget = widget
e.accept()
def _get_drop_index(self, x):
drag_idx = self.layout().indexOf(self.drag_widget)
if drag_idx > 0:
prev_widget = self.layout().itemAt(drag_idx - 1).widget()
if x < self.drag_widget.x() - prev_widget.width() // 2:
return drag_idx - 1
if drag_idx < self.layout().count() - 1:
next_widget = self.layout().itemAt(drag_idx + 1).widget()
if x > self.drag_widget.x() + self.drag_widget.width() + next_widget.width() // 2:
return drag_idx + 1
return drag_idx
def dragMoveEvent(self, e: QDragMoveEvent) -> None:
i = self._get_drop_index(e.pos().x())
self.layout().insertWidget(i, self.drag_widget)
def dropEvent(self, e: QDropEvent):
pos = e.pos()
widget = e.source()
index = self._get_drop_index(pos.x())
self.layout().insertWidget(index, widget)
self.drag_widget = None
e.accept()
self.save()

View file

@ -0,0 +1,434 @@
import platform as pf
import shlex
import shutil
from logging import getLogger
from typing import Optional, Tuple, Iterable
from PyQt5.QtCore import pyqtSignal, QSize, Qt, QMimeData, pyqtSlot, QObject, QEvent
from PyQt5.QtGui import (
QDrag,
QDropEvent,
QDragEnterEvent,
QDragMoveEvent,
QFont,
QMouseEvent,
QShowEvent,
QResizeEvent,
)
from PyQt5.QtWidgets import (
QHBoxLayout,
QLabel,
QFrame,
QMessageBox,
QSizePolicy,
QWidget,
QScrollArea,
QAction,
QMenu,
QPushButton,
QLineEdit,
QVBoxLayout,
QComboBox,
)
from rare.models.wrapper import Wrapper
from rare.shared import RareCore
from rare.utils.misc import qta_icon
from rare.widgets.dialogs import ButtonDialog, game_title
if pf.system() in {"Linux", "FreeBSD"}:
from rare.utils.compat import steam
logger = getLogger("WrapperSettings")
class WrapperEditDialog(ButtonDialog):
result_ready = pyqtSignal(bool, str)
def __init__(self, parent=None):
super(WrapperEditDialog, self).__init__(parent=parent)
self.line_edit = QLineEdit(self)
self.line_edit.textChanged.connect(self.__on_text_changed)
self.widget_layout = QVBoxLayout()
self.widget_layout.addWidget(self.line_edit)
self.setCentralLayout(self.widget_layout)
self.accept_button.setText(self.tr("Save"))
self.accept_button.setIcon(qta_icon("fa.edit"))
self.accept_button.setEnabled(False)
self.result: Tuple = ()
def setup(self, wrapper: Wrapper):
header = self.tr("Edit wrapper")
self.setWindowTitle(header)
self.setSubtitle(game_title(header, wrapper.name))
self.line_edit.setText(wrapper.as_str)
@pyqtSlot(str)
def __on_text_changed(self, text: str):
self.accept_button.setEnabled(bool(text))
def done_handler(self):
self.result_ready.emit(*self.result)
def accept_handler(self):
self.result = (True, self.line_edit.text())
def reject_handler(self):
self.result = (False, self.line_edit.text())
class WrapperAddDialog(WrapperEditDialog):
def __init__(self, parent=None):
super(WrapperAddDialog, self).__init__(parent=parent)
self.combo_box = QComboBox(self)
self.combo_box.addItem("None", "")
self.combo_box.currentIndexChanged.connect(self.__on_index_changed)
self.widget_layout.insertWidget(0, self.combo_box)
def setup(self, wrappers: Iterable[Wrapper]):
header = self.tr("Add wrapper")
self.setWindowTitle(header)
self.setSubtitle(header)
for wrapper in wrappers:
self.combo_box.addItem(f"{wrapper.name} ({wrapper.as_str})", wrapper.as_str)
@pyqtSlot(int)
def __on_index_changed(self, index: int):
command = self.combo_box.itemData(index, Qt.UserRole)
self.line_edit.setText(command)
class WrapperWidget(QFrame):
# object: current, object: new
update_wrapper = pyqtSignal(object, object)
# object: current
delete_wrapper = pyqtSignal(object)
def __init__(self, wrapper: Wrapper, parent=None):
super(WrapperWidget, self).__init__(parent=parent)
self.setFrameShape(QFrame.StyledPanel)
self.setSizePolicy(QSizePolicy.Maximum, QSizePolicy.Fixed)
self.setToolTip(wrapper.as_str)
text_lbl = QLabel(wrapper.name, parent=self)
text_lbl.setFont(QFont("monospace"))
text_lbl.setEnabled(wrapper.is_editable)
image_lbl = QLabel(parent=self)
image_lbl.setPixmap(qta_icon("mdi.drag-vertical").pixmap(QSize(20, 20)))
edit_action = QAction("Edit", parent=self)
edit_action.triggered.connect(self.__on_edit)
delete_action = QAction("Delete", parent=self)
delete_action.triggered.connect(self.__on_delete)
manage_menu = QMenu(parent=self)
manage_menu.addActions([edit_action, delete_action])
manage_button = QPushButton(parent=self)
manage_button.setIcon(qta_icon("mdi.menu", fallback="fa.align-justify"))
manage_button.setMenu(manage_menu)
manage_button.setEnabled(wrapper.is_editable)
if not wrapper.is_editable:
manage_button.setToolTip(self.tr("Manage through settings"))
else:
manage_button.setToolTip(self.tr("Manage"))
layout = QHBoxLayout(self)
layout.setContentsMargins(0, 0, 0, 0)
layout.addWidget(image_lbl)
layout.addWidget(text_lbl)
layout.addWidget(manage_button)
self.setLayout(layout)
self.wrapper = wrapper
# lk: set object names for the stylesheet
self.setObjectName(type(self).__name__)
manage_button.setObjectName(f"{self.objectName()}Button")
def data(self) -> Wrapper:
return self.wrapper
@pyqtSlot()
def __on_delete(self) -> None:
self.delete_wrapper.emit(self.wrapper)
self.deleteLater()
@pyqtSlot()
def __on_edit(self) -> None:
dialog = WrapperEditDialog(self)
dialog.setup(self.wrapper)
dialog.result_ready.connect(self.__on_edit_result)
dialog.show()
@pyqtSlot(bool, str)
def __on_edit_result(self, accepted: bool, command: str):
if accepted and command:
new_wrapper = Wrapper(command=shlex.split(command))
self.update_wrapper.emit(self.wrapper, new_wrapper)
self.deleteLater()
def mouseMoveEvent(self, a0: QMouseEvent) -> None:
if a0.buttons() == Qt.LeftButton:
a0.accept()
if self.wrapper.is_compat_tool:
return
drag = QDrag(self)
mime = QMimeData()
drag.setMimeData(mime)
drag.exec_(Qt.MoveAction)
class WrapperSettingsScroll(QScrollArea):
def __init__(self, parent=None):
super(WrapperSettingsScroll, self).__init__(parent=parent)
self.setFrameShape(QFrame.StyledPanel)
self.setSizeAdjustPolicy(QScrollArea.AdjustToContents)
self.setVerticalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
self.setAlignment(Qt.AlignLeft | Qt.AlignVCenter)
self.setWidgetResizable(True)
self.setProperty("no_kinetic_scroll", True)
self.setObjectName(type(self).__name__)
self.horizontalScrollBar().setObjectName(f"{self.objectName()}Bar")
self.verticalScrollBar().setObjectName(f"{self.objectName()}Bar")
def setWidget(self, w):
super().setWidget(w)
w.installEventFilter(self)
def eventFilter(self, a0: QObject, a1: QEvent) -> bool:
if a0 is self.widget() and a1.type() == QEvent.Resize:
self.__resize(a0)
return a0.event(a1)
return False
def __resize(self, e: QResizeEvent):
minh = self.horizontalScrollBar().minimum()
maxh = self.horizontalScrollBar().maximum()
# lk: when the scrollbar is not visible, min and max are 0
if maxh > minh:
height = (
e.size().height()
+ self.rect().height() // 2
- self.contentsRect().height() // 2
+ self.widget().layout().spacing()
+ self.horizontalScrollBar().sizeHint().height()
)
else:
height = e.size().height() + self.rect().height() - self.contentsRect().height()
self.setMaximumHeight(max(height, self.minimumHeight()))
class WrapperSettings(QWidget):
def __init__(self, parent=None):
super(WrapperSettings, self).__init__(parent=parent)
self.wrapper_label = QLabel(self.tr("No wrappers defined"), self)
self.wrapper_label.setFrameStyle(QLabel.StyledPanel | QLabel.Plain)
self.wrapper_label.setSizePolicy(QSizePolicy.Fixed, QSizePolicy.Fixed)
self.add_button = QPushButton(self.tr("Add wrapper"), self)
self.add_button.clicked.connect(self.__on_add)
self.wrapper_scroll = WrapperSettingsScroll(self)
self.wrapper_scroll.setMinimumHeight(self.add_button.minimumSizeHint().height())
self.wrapper_container = WrapperContainer(self.wrapper_label, self.wrapper_scroll)
self.wrapper_container.orderChanged.connect(self.__on_order_changed)
self.wrapper_scroll.setWidget(self.wrapper_container)
# lk: set object names for the stylesheet
self.setObjectName("WrapperSettings")
self.wrapper_label.setObjectName(f"{self.objectName()}Label")
main_layout = QHBoxLayout(self)
main_layout.setContentsMargins(0, 0, 0, 0)
main_layout.addWidget(self.wrapper_scroll, alignment=Qt.AlignTop)
main_layout.addWidget(self.add_button, alignment=Qt.AlignTop)
self.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed)
self.app_name: str = "default"
self.core = RareCore.instance().core()
self.wrappers = RareCore.instance().wrappers()
def showEvent(self, a0: QShowEvent):
if a0.spontaneous():
return super().showEvent(a0)
self.update_state()
return super().showEvent(a0)
@pyqtSlot(QWidget, int)
def __on_order_changed(self, widget: WrapperWidget, new_index: int):
wrapper = widget.data()
wrappers = self.wrappers.get_game_wrapper_list(self.app_name)
wrappers.remove(wrapper)
wrappers.insert(new_index, wrapper)
self.wrappers.set_game_wrapper_list(self.app_name, wrappers)
@pyqtSlot()
def __on_add(self) -> None:
dialog = WrapperAddDialog(self)
dialog.setup(self.wrappers.user_wrappers)
dialog.result_ready.connect(self.__on_add_result)
dialog.show()
@pyqtSlot(bool, str)
def __on_add_result(self, accepted: bool, command: str):
if accepted and command:
wrapper = Wrapper(shlex.split(command))
self.add_user_wrapper(wrapper)
def __add_wrapper(self, wrapper: Wrapper, position: int = -1):
self.wrapper_label.setVisible(False)
widget = WrapperWidget(wrapper, self.wrapper_container)
if position < 0:
self.wrapper_container.addWidget(widget)
else:
self.wrapper_container.insertWidget(position, widget)
widget.update_wrapper.connect(self.__update_wrapper)
widget.delete_wrapper.connect(self.__delete_wrapper)
def add_wrapper(self, wrapper: Wrapper, position: int = -1):
wrappers = self.wrappers.get_game_wrapper_list(self.app_name)
if position < 0 or wrapper.is_compat_tool:
wrappers.append(wrapper)
else:
wrappers.insert(position, wrapper)
self.wrappers.set_game_wrapper_list(self.app_name, wrappers)
self.__add_wrapper(wrapper, position)
def add_user_wrapper(self, wrapper: Wrapper, position: int = -1):
if not wrapper:
return
if pf.system() in {"Linux", "FreeBSD"}:
compat_cmds = [tool.command() for tool in steam.find_tools()]
if wrapper.as_str in compat_cmds:
QMessageBox.warning(
self,
self.tr("Warning"),
self.tr("Do not insert compatibility tools manually. Add them through Proton settings"),
)
return
if wrapper.checksum in self.wrappers.get_game_md5sum_list(self.app_name):
QMessageBox.warning(
self,
self.tr("Warning"),
self.tr("Wrapper <b>{0}</b> is already in the list").format(wrapper.as_str),
)
return
if not shutil.which(wrapper.executable):
ans = QMessageBox.question(
self,
self.tr("Warning"),
self.tr("Wrapper <b>{0}</b> is not in $PATH. Add it anyway?").format(wrapper.executable),
QMessageBox.Yes | QMessageBox.No,
QMessageBox.No,
)
if ans == QMessageBox.No:
return
self.add_wrapper(wrapper, position)
@pyqtSlot(object)
def __delete_wrapper(self, wrapper: Wrapper):
wrappers = self.wrappers.get_game_wrapper_list(self.app_name)
wrappers.remove(wrapper)
self.wrappers.set_game_wrapper_list(self.app_name, wrappers)
if not wrappers:
self.wrapper_label.setVisible(True)
@pyqtSlot(object, object)
def __update_wrapper(self, old: Wrapper, new: Wrapper):
wrappers = self.wrappers.get_game_wrapper_list(self.app_name)
index = wrappers.index(old)
wrappers.remove(old)
wrappers.insert(index, new)
self.wrappers.set_game_wrapper_list(self.app_name, wrappers)
self.__add_wrapper(new, index)
@pyqtSlot()
def update_state(self):
for w in self.wrapper_container.findChildren(WrapperWidget, options=Qt.FindDirectChildrenOnly):
w.deleteLater()
wrappers = self.wrappers.get_game_wrapper_list(self.app_name)
if not wrappers:
self.wrapper_label.setVisible(True)
for wrapper in wrappers:
self.__add_wrapper(wrapper)
class WrapperContainer(QWidget):
# QWidget: moving widget, int: new index
orderChanged: pyqtSignal = pyqtSignal(QWidget, int)
def __init__(self, label: QLabel, parent=None):
super(WrapperContainer, self).__init__(parent=parent)
self.setAcceptDrops(True)
self.__layout = QHBoxLayout()
self.__drag_widget: Optional[QWidget] = None
main_layout = QHBoxLayout(self)
main_layout.addWidget(label)
main_layout.addLayout(self.__layout)
main_layout.setContentsMargins(0, 0, 0, 0)
main_layout.setAlignment(Qt.AlignLeft | Qt.AlignVCenter)
main_layout.setSizeConstraint(QHBoxLayout.SetFixedSize)
# lk: set object names for the stylesheet
self.setObjectName(type(self).__name__)
# def count(self) -> int:
# return self.__layout.count()
#
# def itemData(self, index: int) -> Any:
# widget: WrapperWidget = self.__layout.itemAt(index).widget()
# return widget.data()
def addWidget(self, widget: WrapperWidget):
self.__layout.addWidget(widget)
def insertWidget(self, index: int, widget: WrapperWidget):
self.__layout.insertWidget(index, widget)
def dragEnterEvent(self, e: QDragEnterEvent):
widget = e.source()
self.__drag_widget = widget
e.accept()
def __get_drop_index(self, x) -> int:
drag_idx = self.__layout.indexOf(self.__drag_widget)
if drag_idx > 0:
prev_widget = self.__layout.itemAt(drag_idx - 1).widget()
if x < self.__drag_widget.x() - prev_widget.width() // 2:
return drag_idx - 1
if drag_idx < self.__layout.count() - 1:
next_widget = self.__layout.itemAt(drag_idx + 1).widget()
if x > self.__drag_widget.x() + self.__drag_widget.width() + next_widget.width() // 2:
return drag_idx + 1
return drag_idx
def dragMoveEvent(self, e: QDragMoveEvent) -> None:
new_x = self.__get_drop_index(e.pos().x())
self.__layout.insertWidget(new_x, self.__drag_widget)
def dropEvent(self, e: QDropEvent):
pos = e.pos()
widget = e.source()
new_x = self.__get_drop_index(pos.x())
self.__layout.insertWidget(new_x, widget)
self.__drag_widget = None
self.orderChanged.emit(widget, new_x)
e.accept()

View file

@ -1,61 +1,41 @@
from PyQt5.QtGui import QShowEvent, QHideEvent
from PyQt5.QtWidgets import QStackedWidget, QTabWidget
from legendary.core import LegendaryCore
from rare.shared.rare_core import RareCore
from rare.utils.paths import cache_dir
from .game_info import ShopGameInfo
from .search_results import SearchResults
from .shop_api_core import ShopApiCore
from .shop_widget import ShopWidget
from .wishlist import WishlistWidget, Wishlist
from rare.widgets.side_tab import SideTabWidget
from .api.models.response import CatalogOfferModel
from .landing import LandingWidget, LandingPage
from .search import SearchPage
from .store_api import StoreAPI
from .wishlist import WishlistPage
class Shop(QStackedWidget):
init = False
class StoreTab(SideTabWidget):
def __init__(self, core: LegendaryCore, parent=None):
super(StoreTab, self).__init__(parent=parent)
self.init = False
def __init__(self, core: LegendaryCore):
super(Shop, self).__init__()
self.core = core
self.rcore = RareCore.instance()
self.api_core = ShopApiCore(
# self.rcore = RareCore.instance()
self.api = StoreAPI(
self.core.egs.session.headers["Authorization"],
self.core.language_code,
self.core.country_code,
[] # [i.asset_infos["Windows"].namespace for i in self.rcore.game_list if bool(i.asset_infos)]
)
self.shop = ShopWidget(cache_dir(), self.core, self.api_core)
self.wishlist_widget = Wishlist(self.api_core)
self.landing = LandingPage(self.api, parent=self)
self.landing_index = self.addTab(self.landing, self.tr("Store"))
self.store_tabs = QTabWidget(parent=self)
self.store_tabs.addTab(self.shop, self.tr("Games"))
self.store_tabs.addTab(self.wishlist_widget, self.tr("Wishlist"))
self.search = SearchPage(self.api, parent=self)
self.search_index = self.addTab(self.search, self.tr("Search"))
self.addWidget(self.store_tabs)
self.search_results = SearchResults(self.api_core)
self.addWidget(self.search_results)
self.search_results.show_info.connect(self.show_game_info)
self.info = ShopGameInfo(
[i.asset_infos["Windows"].namespace for i in self.rcore.game_list if bool(i.asset_infos)],
self.api_core,
)
self.addWidget(self.info)
self.info.back_button.clicked.connect(lambda: self.setCurrentIndex(0))
self.search_results.back_button.clicked.connect(lambda: self.setCurrentIndex(0))
self.shop.show_info.connect(self.show_search_results)
self.wishlist_widget.show_game_info.connect(self.show_game_info)
self.shop.show_game.connect(self.show_game_info)
self.api_core.update_wishlist.connect(self.update_wishlist)
self.wishlist_widget.update_wishlist_signal.connect(self.update_wishlist)
self.wishlist = WishlistPage(self.api, parent=self)
self.wishlist_index = self.addTab(self.wishlist, self.tr("Wishlist"))
def showEvent(self, a0: QShowEvent) -> None:
if a0.spontaneous() or self.init:
return super().showEvent(a0)
self.shop.load()
self.wishlist_widget.update_wishlist()
self.init = True
return super().showEvent(a0)
@ -64,14 +44,3 @@ class Shop(QStackedWidget):
return super().hideEvent(a0)
# TODO: Implement store unloading
return super().hideEvent(a0)
def update_wishlist(self):
self.shop.update_wishlist()
def show_game_info(self, data):
self.info.update_game(data)
self.setCurrentIndex(2)
def show_search_results(self, text: str):
self.search_results.load_results(text)
self.setCurrentIndex(1)

View file

@ -0,0 +1,39 @@
import sys
from PyQt5.QtCore import QSize
from PyQt5.QtWidgets import QDialog, QApplication, QVBoxLayout
from legendary.core import LegendaryCore
from . import StoreTab
class StoreWindow(QDialog):
def __init__(self):
super().__init__()
self.core = LegendaryCore()
self.core.login()
self.store_tab = StoreTab(self.core, self)
layout = QVBoxLayout(self)
layout.addWidget(self.store_tab)
self.store_tab.show()
if __name__ == "__main__":
import rare.resources.static_css
# import rare.resources.stylesheets.RareStyle
from rare.utils.misc import set_style_sheet
app = QApplication(sys.argv)
app.setApplicationName("Rare")
app.setOrganizationName("Rare")
set_style_sheet("")
set_style_sheet("RareStyle")
window = StoreWindow()
window.setWindowTitle(f"{app.applicationName()} - Store")
window.resize(QSize(1280, 800))
window.show()
app.exec()

View file

@ -0,0 +1,568 @@
FEED_QUERY = '''
query feedQuery(
$locale: String!
$countryCode: String
$offset: Int
$postsPerPage: Int
$category: String
) {
TransientStream {
myTransientFeed(countryCode: $countryCode, locale: $locale) {
id
activity {
... on LinkAccountActivity {
type
created_at
platforms
}
... on SuggestedFriendsActivity {
type
created_at
platform
suggestions {
epicId
epicDisplayName
platformFullName
platformAvatar
}
}
... on IncomingInvitesActivity {
type
created_at
invites {
epicId
epicDisplayName
}
}
... on RecentPlayersActivity {
type
created_at
players {
epicId
epicDisplayName
playedGameName
}
}
}
}
}
Blog {
dieselBlogPosts: getPosts(
locale: $locale
offset: $offset
postsPerPage: $postsPerPage
category: $category
) {
blogList {
_id
author
category
content
urlPattern
slug
sticky
title
date
image
shareImage
trendingImage
url
featured
link
externalLink
}
}
}
}
'''
REVIEWS_QUERY = '''
query productReviewsQuery($sku: String!) {
OpenCritic {
productReviews(sku: $sku) {
id
name
openCriticScore
reviewCount
percentRecommended
openCriticUrl
award
topReviews {
publishedDate
externalUrl
snippet
language
score
author
ScoreFormat {
id
description
}
OutletId
outletName
displayScore
}
}
}
}
'''
MEDIA_QUERY = '''
query fetchMediaRef($mediaRefId: String!) {
Media {
getMediaRef(mediaRefId: $mediaRefId) {
accountId
outputs {
duration
url
width
height
key
contentType
}
namespace
}
}
}
'''
ADDONS_QUERY = '''
query getAddonsByNamespace(
$categories: String!
$count: Int!
$country: String!
$locale: String!
$namespace: String!
$sortBy: String!
$sortDir: String!
) {
Catalog {
catalogOffers(
namespace: $namespace
locale: $locale
params: {
category: $categories
count: $count
country: $country
sortBy: $sortBy
sortDir: $sortDir
}
) {
elements {
countriesBlacklist
customAttributes {
key
value
}
description
developer
effectiveDate
id
isFeatured
keyImages {
type
url
}
lastModifiedDate
longDescription
namespace
offerType
productSlug
releaseDate
status
technicalDetails
title
urlSlug
}
}
}
}
'''
CATALOG_QUERY = '''
query catalogQuery(
$category: String
$count: Int
$country: String!
$keywords: String
$locale: String
$namespace: String!
$sortBy: String
$sortDir: String
$start: Int
$tag: String
) {
Catalog {
catalogOffers(
namespace: $namespace
locale: $locale
params: {
count: $count
country: $country
category: $category
keywords: $keywords
sortBy: $sortBy
sortDir: $sortDir
start: $start
tag: $tag
}
) {
elements {
isFeatured
collectionOfferIds
title
id
namespace
description
keyImages {
type
url
}
seller {
id
name
}
productSlug
urlSlug
items {
id
namespace
}
customAttributes {
key
value
}
categories {
path
}
price(country: $country) {
totalPrice {
discountPrice
originalPrice
voucherDiscount
discount
fmtPrice(locale: $locale) {
originalPrice
discountPrice
intermediatePrice
}
}
lineOffers {
appliedRules {
id
endDate
}
}
}
linkedOfferId
linkedOffer {
effectiveDate
customAttributes {
key
value
}
}
}
paging {
count
total
}
}
}
}
'''
CATALOG_TAGS_QUERY = '''
query catalogTags($namespace: String!) {
Catalog {
tags(namespace: $namespace, start: 0, count: 999) {
elements {
aliases
id
name
referenceCount
status
}
}
}
}
'''
PREREQUISITES_QUERY = '''
query fetchPrerequisites($offerParams: [OfferParams]) {
Launcher {
prerequisites(offerParams: $offerParams) {
namespace
offerId
missingPrerequisiteItems
satisfiesPrerequisites
}
}
}
'''
PROMOTIONS_QUERY = '''
query promotionsQuery(
$namespace: String!
$country: String!
$locale: String!
) {
Catalog {
catalogOffers(
namespace: $namespace
locale: $locale
params: {
category: "freegames"
country: $country
sortBy: "effectiveDate"
sortDir: "asc"
}
) {
elements {
title
description
id
namespace
categories {
path
}
linkedOfferNs
linkedOfferId
keyImages {
type
url
}
productSlug
promotions {
promotionalOffers {
promotionalOffers {
startDate
endDate
discountSetting {
discountType
discountPercentage
}
}
}
upcomingPromotionalOffers {
promotionalOffers {
startDate
endDate
discountSetting {
discountType
discountPercentage
}
}
}
}
}
}
}
}
'''
OFFERS_QUERY = '''
query catalogQuery(
$productNamespace: String!
$offerId: String!
$locale: String
$country: String!
$includeSubItems: Boolean!
) {
Catalog {
catalogOffer(namespace: $productNamespace, id: $offerId, locale: $locale) {
title
id
namespace
description
effectiveDate
expiryDate
isCodeRedemptionOnly
keyImages {
type
url
}
seller {
id
name
}
productSlug
urlSlug
url
tags {
id
}
items {
id
namespace
}
customAttributes {
key
value
}
categories {
path
}
price(country: $country) {
totalPrice {
discountPrice
originalPrice
voucherDiscount
discount
currencyCode
currencyInfo {
decimals
}
fmtPrice(locale: $locale) {
originalPrice
discountPrice
intermediatePrice
}
}
lineOffers {
appliedRules {
id
endDate
discountSetting {
discountType
}
}
}
}
}
offerSubItems(namespace: $productNamespace, id: $offerId)
@include(if: $includeSubItems) {
namespace
id
releaseInfo {
appId
platform
}
}
}
}
'''
SEARCH_STORE_QUERY = '''
query searchStoreQuery(
$allowCountries: String
$category: String
$count: Int
$country: String!
$keywords: String
$locale: String
$namespace: String
$itemNs: String
$sortBy: String
$sortDir: String
$start: Int
$tag: String
$releaseDate: String
$withPrice: Boolean = false
$withPromotions: Boolean = false
) {
Catalog {
searchStore(
allowCountries: $allowCountries
category: $category
count: $count
country: $country
keywords: $keywords
locale: $locale
namespace: $namespace
itemNs: $itemNs
sortBy: $sortBy
sortDir: $sortDir
releaseDate: $releaseDate
start: $start
tag: $tag
) {
elements {
title
id
namespace
description
effectiveDate
keyImages {
type
url
}
seller {
id
name
}
productSlug
urlSlug
url
tags {
id
}
items {
id
namespace
}
customAttributes {
key
value
}
categories {
path
}
price(country: $country) @include(if: $withPrice) {
totalPrice {
discountPrice
originalPrice
voucherDiscount
discount
currencyCode
currencyInfo {
decimals
}
fmtPrice(locale: $locale) {
originalPrice
discountPrice
intermediatePrice
}
}
lineOffers {
appliedRules {
id
endDate
discountSetting {
discountType
}
}
}
}
promotions(category: $category) @include(if: $withPromotions) {
promotionalOffers {
promotionalOffers {
startDate
endDate
discountSetting {
discountType
discountPercentage
}
}
}
upcomingPromotionalOffers {
promotionalOffers {
startDate
endDate
discountSetting {
discountType
discountPercentage
}
}
}
}
}
paging {
count
total
}
}
}
}
'''

View file

@ -0,0 +1,29 @@
from PyQt5.QtCore import Qt
from PyQt5.QtWidgets import QTreeView, QDialog, QVBoxLayout
from rare.utils.json_formatter import QJsonModel
class DebugView(QTreeView):
def __init__(self, data, parent=None):
super(DebugView, self).__init__(parent=parent)
self.setColumnWidth(0, 300)
self.setWordWrap(True)
self.model = QJsonModel(self)
self.setModel(self.model)
self.setContextMenuPolicy(Qt.ActionsContextMenu)
try:
self.model.load(data)
except Exception as e:
pass
self.resizeColumnToContents(0)
class DebugDialog(QDialog):
def __init__(self, data, parent=None):
super().__init__(parent=parent)
self.resize(800, 600)
layout = QVBoxLayout(self)
view = DebugView(data, self)
layout.addWidget(view)

View file

@ -0,0 +1,15 @@
{
"name": "EGS GraphQL Schema",
"schemaPath": "schema.graphql",
"extensions": {
"endpoints": {
"Default GraphQL Endpoint": {
"url": "http://localhost:8080/graphql",
"headers": {
"user-agent": "JS GraphQL"
},
"introspect": false
}
}
}
}

View file

@ -0,0 +1,76 @@
scalar Date
type Currency {
decimals: Int
symbol: String
}
type FormattedPrice {
originalPrice: String
discountPrice: String
intermediatePrice: String
}
type TotalPrice {
discountPrice: Int
originalPrice: Int
voucherDiscount: Int
discount: Int
currencyCode: String
currencyInfo: Currency
fmtPrice(locale: String): FormattedPrice
}
type DiscountSetting {
discountType: String
}
type AppliedRules {
id: ID
endDate: Date
discountSetting: DiscountSetting
}
type LineOfferRes {
appliedRules: [AppliedRules]
}
type GetPriceRes {
totalPrice: TotalPrice
lineOffers: [LineOfferRes]
}
type Image {
type: String
url: String
alt: String
}
type StorePageMapping {
cmsSlug: String
offerId: ID
prePurchaseOfferId: ID
}
type PageSandboxModel {
pageSlug: String
pageType: String
productId: ID
sandboxId: ID
createdDate: Date
updatedDate: Date
deletedDate: Date
mappings: [StorePageMapping]
}
type CatalogNamespace {
parent: ID
displayName: String
store: String
mappings: [PageSandboxModel]
}
type CatalogItem {
id: ID
namespace: ID
}

View file

@ -0,0 +1,164 @@
import logging
from dataclasses import dataclass, field
from typing import List, Dict, Any, Type, Optional
logger = logging.getLogger("DieselModels")
# lk: Typing overloads for unimplemented types
DieselSocialLinks = Dict
@dataclass
class DieselSystemDetailItem:
_type: Optional[str] = None
minimum: Optional[str] = None
recommended: Optional[str] = None
title: Optional[str] = None
unmapped: Dict[str, Any] = field(default_factory=dict)
@classmethod
def from_dict(cls: Type["DieselSystemDetailItem"], src: Dict[str, Any]) -> "DieselSystemDetailItem":
d = src.copy()
tmp = cls(
_type=d.pop("_type", ""),
minimum=d.pop("minimum", ""),
recommended=d.pop("recommended", ""),
title=d.pop("title", ""),
)
tmp.unmapped = d
return tmp
@dataclass
class DieselSystemDetail:
_type: Optional[str] = None
details: Optional[List[DieselSystemDetailItem]] = None
systemType: Optional[str] = None
unmapped: Dict[str, Any] = field(default_factory=dict)
@classmethod
def from_dict(cls: Type["DieselSystemDetail"], src: Dict[str, Any]) -> "DieselSystemDetail":
d = src.copy()
_details = d.pop("details", [])
details = [] if _details else None
for item in _details:
detail = DieselSystemDetailItem.from_dict(item)
details.append(detail)
tmp = cls(
_type=d.pop("_type", ""),
details=details,
systemType=d.pop("systemType", ""),
)
tmp.unmapped = d
return tmp
@dataclass
class DieselSystemDetails:
_type: Optional[str] = None
languages: Optional[List[str]] = None
rating: Optional[Dict] = None
systems: Optional[List[DieselSystemDetail]] = None
unmapped: Dict[str, Any] = field(default_factory=dict)
@classmethod
def from_dict(cls: Type["DieselSystemDetails"], src: Dict[str, Any]) -> "DieselSystemDetails":
d = src.copy()
_systems = d.pop("systems", [])
systems = [] if _systems else None
for item in _systems:
system = DieselSystemDetail.from_dict(item)
systems.append(system)
tmp = cls(
_type=d.pop("_type", ""),
languages=d.pop("languages", []),
rating=d.pop("rating", {}),
systems=systems,
)
tmp.unmapped = d
return tmp
@dataclass
class DieselProductAbout:
_type: Optional[str] = None
desciption: Optional[str] = None
developerAttribution: Optional[str] = None
publisherAttribution: Optional[str] = None
shortDescription: Optional[str] = None
unmapped: Dict[str, Any] = field(default_factory=dict)
@classmethod
def from_dict(cls: Type["DieselProductAbout"], src: Dict[str, Any]) -> "DieselProductAbout":
d = src.copy()
tmp = cls(
_type=d.pop("_type", ""),
desciption=d.pop("description", ""),
developerAttribution=d.pop("developerAttribution", ""),
publisherAttribution=d.pop("publisherAttribution", ""),
shortDescription=d.pop("shortDescription", ""),
)
tmp.unmapped = d
return tmp
@dataclass
class DieselProductDetail:
_type: Optional[str] = None
about: Optional[DieselProductAbout] = None
requirements: Optional[DieselSystemDetails] = None
socialLinks: Optional[DieselSocialLinks] = None
unmapped: Dict[str, Any] = field(default_factory=dict)
@classmethod
def from_dict(cls: Type["DieselProductDetail"], src: Dict[str, Any]) -> "DieselProductDetail":
d = src.copy()
about = DieselProductAbout.from_dict(x) if (x := d.pop("about"), {}) else None
requirements = DieselSystemDetails.from_dict(x) if (x := d.pop("requirements", {})) else None
tmp = cls(
_type=d.pop("_type", ""),
about=about,
requirements=requirements,
socialLinks=d.pop("socialLinks", {}),
)
tmp.unmapped = d
return tmp
@dataclass
class DieselProduct:
_id: Optional[str] = None
_images_: Optional[List[str]] = None
_locale: Optional[str] = None
_slug: Optional[str] = None
_title: Optional[str] = None
_urlPattern: Optional[str] = None
namespace: Optional[str] = None
pages: Optional[List["DieselProduct"]] = None
data: Optional[DieselProductDetail] = None
productName: Optional[str] = None
unmapped: Dict[str, Any] = field(default_factory=dict)
@classmethod
def from_dict(cls: Type["DieselProduct"], src: Dict[str, Any]) -> "DieselProduct":
d = src.copy()
_pages = d.pop("pages", [])
pages = [] if _pages else None
for item in _pages:
page = DieselProduct.from_dict(item)
pages.append(page)
data = DieselProductDetail.from_dict(x) if (x := d.pop("data", {})) else None
tmp = cls(
_id=d.pop("_id", ""),
_images_=d.pop("_images_", []),
_locale=d.pop("_locale", ""),
_slug=d.pop("_slug", ""),
_title=d.pop("_title", ""),
_urlPattern=d.pop("_urlPattern", ""),
namespace=d.pop("namespace", ""),
pages=pages,
data=data,
productName=d.pop("productName", ""),
)
tmp.unmapped = d
return tmp

View file

@ -0,0 +1,80 @@
from dataclasses import dataclass, field
from datetime import datetime, timezone
from typing import List
@dataclass
class SearchDateRange:
start_date: datetime = datetime(year=1990, month=1, day=1, tzinfo=timezone.utc)
end_date: datetime = datetime.utcnow().replace(tzinfo=timezone.utc)
def __str__(self):
def fmt_date(date: datetime) -> str:
# lk: The formatting accepted by the GraphQL API is either '%Y-%m-%dT%H:%M:%S.000Z' or '%Y-%m-%d'
return datetime.strftime(date, '%Y-%m-%dT%H:%M:%S.000Z')
return f"[{fmt_date(self.start_date)},{fmt_date(self.end_date)}]"
@dataclass
class SearchStoreQuery:
country: str = "US"
category: str = "games/edition/base|bundles/games|editors|software/edition/base"
count: int = 30
keywords: str = ""
language: str = "en"
namespace: str = ""
with_mapping: bool = True
item_ns: str = ""
sort_by: str = "releaseDate"
sort_dir: str = "DESC"
start: int = 0
tag: List[str] = ""
release_date: SearchDateRange = field(default_factory=SearchDateRange)
with_price: bool = True
with_promotions: bool = True
price_range: str = ""
free_game: bool = None
on_sale: bool = None
effective_date: SearchDateRange = field(default_factory=SearchDateRange)
def __post_init__(self):
self.locale = f"{self.language}-{self.country}"
def to_dict(self):
payload = {
"allowCountries": self.country,
"category": self.category,
"count": self.count,
"country": self.country,
"keywords": self.keywords,
"locale": self.locale,
"namespace": self.namespace,
"withMapping": self.with_mapping,
"itemNs": self.item_ns,
"sortBy": self.sort_by,
"sortDir": self.sort_dir,
"start": self.start,
"tag": self.tag,
"releaseDate": str(self.release_date),
"withPrice": self.with_price,
"withPromotions": self.with_promotions,
"priceRange": self.price_range,
"freeGame": self.free_game,
"onSale": self.on_sale,
"effectiveDate": str(self.effective_date),
}
# payload.pop("withPromotions")
payload.pop("onSale")
if self.price_range == "free":
payload["freeGame"] = True
payload.pop("priceRange")
elif self.price_range.startswith("<price>"):
payload["priceRange"] = self.price_range.replace("<price>", "")
if self.on_sale:
payload["onSale"] = True
if self.price_range:
payload["effectiveDate"] = self.effective_date
else:
payload.pop("priceRange")
return payload

View file

@ -0,0 +1,480 @@
import logging
from dataclasses import dataclass, field
from datetime import datetime
from typing import List, Dict, Any, Type, Optional, Tuple
from .utils import parse_date
logger = logging.getLogger("StoreApiModels")
# lk: Typing overloads for unimplemented types
DieselSocialLinks = Dict
CatalogNamespaceModel = Dict
CategoryModel = Dict
CustomAttributeModel = Dict
ItemModel = Dict
SellerModel = Dict
PageSandboxModel = Dict
TagModel = Dict
@dataclass
class ImageUrlModel:
type: Optional[str] = None
url: Optional[str] = None
def to_dict(self) -> Dict[str, Any]:
tmp: Dict[str, Any] = {}
tmp.update({})
if self.type is not None:
tmp["type"] = self.type
if self.url is not None:
tmp["url"] = self.url
return tmp
@classmethod
def from_dict(cls: Type["ImageUrlModel"], src: Dict[str, Any]) -> "ImageUrlModel":
d = src.copy()
type = d.pop("type", None)
url = d.pop("url", None)
tmp = cls(type=type, url=url)
return tmp
@dataclass
class KeyImagesModel:
key_images: Optional[List[ImageUrlModel]] = None
tall_types = ("DieselStoreFrontTall", "OfferImageTall", "Thumbnail", "ProductLogo", "DieselGameBoxLogo")
wide_types = ("DieselStoreFrontWide", "OfferImageWide", "VaultClosed", "ProductLogo")
def __getitem__(self, item):
return self.key_images[item]
def __bool__(self):
return bool(self.key_images)
def to_list(self) -> List[Dict[str, Any]]:
items: Optional[List[Dict[str, Any]]] = None
if self.key_images is not None:
items = []
for image_url in self.key_images:
item = image_url.to_dict()
items.append(item)
return items
@classmethod
def from_list(cls: Type["KeyImagesModel"], src: List[Dict]):
d = src.copy()
key_images = []
for item in d:
image_url = ImageUrlModel.from_dict(item)
key_images.append(image_url)
tmp = cls(key_images)
return tmp
def available_tall(self) -> List[ImageUrlModel]:
tall_images = filter(lambda img: img.type in KeyImagesModel.tall_types, self.key_images)
tall_images = sorted(tall_images, key=lambda x: KeyImagesModel.tall_types.index(x.type))
return tall_images
def available_wide(self) -> List[ImageUrlModel]:
wide_images = filter(lambda img: img.type in KeyImagesModel.wide_types, self.key_images)
wide_images = sorted(wide_images, key=lambda x: KeyImagesModel.wide_types.index(x.type))
return wide_images
def for_dimensions(self, w: int, h: int) -> ImageUrlModel:
try:
if w > h:
model = self.available_wide()[0]
else:
model = self.available_tall()[0]
_ = model.url
except Exception as e:
logger.error(e)
logger.error(self.to_list())
else:
return model
CurrencyModel = Dict
FormattedPriceModel = Dict
LineOffersModel = Dict
@dataclass
class TotalPriceModel:
discountPrice: Optional[int] = None
originalPrice: Optional[int] = None
voucherDiscount: Optional[int] = None
discount: Optional[int] = None
currencyCode: Optional[str] = None
currencyInfo: Optional[CurrencyModel] = None
fmtPrice: Optional[FormattedPriceModel] = None
unmapped: Dict[str, Any] = field(default_factory=dict)
@classmethod
def from_dict(cls: Type["TotalPriceModel"], src: Dict[str, Any]) -> "TotalPriceModel":
d = src.copy()
tmp = cls(
discountPrice=d.pop("discountPrice", None),
originalPrice=d.pop("originalPrice", None),
voucherDiscount=d.pop("voucherDiscount", None),
discount=d.pop("discount", None),
currencyCode=d.pop("currencyCode", None),
currencyInfo=d.pop("currrencyInfo", {}),
fmtPrice=d.pop("fmtPrice", {}),
)
tmp.unmapped = d
return tmp
@dataclass
class GetPriceResModel:
totalPrice: Optional[TotalPriceModel] = None
lineOffers: Optional[LineOffersModel] = None
unmapped: Dict[str, Any] = field(default_factory=dict)
@classmethod
def from_dict(cls: Type["GetPriceResModel"], src: Dict[str, Any]) -> "GetPriceResModel":
d = src.copy()
total_price = TotalPriceModel.from_dict(x) if (x := d.pop("totalPrice", {})) else None
tmp = cls(totalPrice=total_price, lineOffers=d.pop("lineOffers", {}))
tmp.unmapped = d
return tmp
DiscountSettingModel = Dict
@dataclass
class PromotionalOfferModel:
startDate: Optional[datetime] = None
endDate: Optional[datetime] = None
discountSetting: Optional[DiscountSettingModel] = None
@classmethod
def from_dict(cls: Type["PromotionalOfferModel"], src: Dict[str, Any]) -> "PromotionalOfferModel":
d = src.copy()
start_date = parse_date(x) if (x := d.pop("startDate", "")) else None
end_date = parse_date(x) if (x := d.pop("endDate", "")) else None
tmp = cls(startDate=start_date, endDate=end_date, discountSetting=d.pop("discountSetting", {}))
tmp.unmapped = d
return tmp
@dataclass
class PromotionalOffersModel:
promotionalOffers: Optional[Tuple[PromotionalOfferModel]] = None
@classmethod
def from_list(cls: Type["PromotionalOffersModel"], src: Dict[str, List]) -> "PromotionalOffersModel":
d = src.copy()
promotional_offers = (
tuple([PromotionalOfferModel.from_dict(y) for y in x]) if (x := d.pop("promotionalOffers", [])) else None
)
tmp = cls(promotionalOffers=promotional_offers)
tmp.unmapped = d
return tmp
@dataclass
class PromotionsModel:
promotionalOffers: Optional[Tuple[PromotionalOffersModel]] = None
upcomingPromotionalOffers: Optional[Tuple[PromotionalOffersModel]] = None
@classmethod
def from_dict(cls: Type["PromotionsModel"], src: Dict[str, Any]) -> "PromotionsModel":
d = src.copy()
promotional_offers = (
tuple([PromotionalOffersModel.from_list(y) for y in x]) if (x := d.pop("promotionalOffers", [])) else None
)
upcoming_promotional_offers = (
tuple([PromotionalOffersModel.from_list(y) for y in x])
if (x := d.pop("upcomingPromotionalOffers", []))
else None
)
tmp = cls(promotionalOffers=promotional_offers, upcomingPromotionalOffers=upcoming_promotional_offers)
tmp.unmapped = d
return tmp
@dataclass
class CatalogOfferModel:
catalogNs: Optional[CatalogNamespaceModel] = None
categories: Optional[List[CategoryModel]] = None
customAttributes: Optional[List[CustomAttributeModel]] = None
description: Optional[str] = None
effectiveDate: Optional[datetime] = None
expiryDate: Optional[datetime] = None
id: Optional[str] = None
isCodeRedemptionOnly: Optional[bool] = None
items: Optional[List[ItemModel]] = None
keyImages: Optional[KeyImagesModel] = None
namespace: Optional[str] = None
offerMappings: Optional[List[PageSandboxModel]] = None
offerType: Optional[str] = None
price: Optional[GetPriceResModel] = None
productSlug: Optional[str] = None
promotions: Optional[PromotionsModel] = None
seller: Optional[SellerModel] = None
status: Optional[str] = None
tags: Optional[List[TagModel]] = None
title: Optional[str] = None
url: Optional[str] = None
urlSlug: Optional[str] = None
viewableDate: Optional[datetime] = None
unmapped: Dict[str, Any] = field(default_factory=dict)
@classmethod
def from_dict(cls: Type["CatalogOfferModel"], src: Dict[str, Any]) -> "CatalogOfferModel":
d = src.copy()
effective_date = parse_date(x) if (x := d.pop("effectiveDate", "")) else None
expiry_date = parse_date(x) if (x := d.pop("expiryDate", "")) else None
key_images = KeyImagesModel.from_list(d.pop("keyImages", []))
price = GetPriceResModel.from_dict(x) if (x := d.pop("price", {})) else None
promotions = PromotionsModel.from_dict(x) if (x := d.pop("promotions", {})) else None
viewable_date = parse_date(x) if (x := d.pop("viewableDate", "")) else None
tmp = cls(
catalogNs=d.pop("catalogNs", {}),
categories=d.pop("categories", []),
customAttributes=d.pop("customAttributes", []),
description=d.pop("description", ""),
effectiveDate=effective_date,
expiryDate=expiry_date,
id=d.pop("id", ""),
isCodeRedemptionOnly=d.pop("isCodeRedemptionOnly", None),
items=d.pop("items", []),
keyImages=key_images,
namespace=d.pop("namespace", ""),
offerMappings=d.pop("offerMappings", []),
offerType=d.pop("offerType", ""),
price=price,
productSlug=d.pop("productSlug", ""),
promotions=promotions,
seller=d.pop("seller", {}),
status=d.pop("status", ""),
tags=d.pop("tags", []),
title=d.pop("title", ""),
url=d.pop("url", ""),
urlSlug=d.pop("urlSlug", ""),
viewableDate=viewable_date,
)
tmp.unmapped = d
return tmp
@dataclass
class WishlistItemModel:
created: Optional[datetime] = None
id: Optional[str] = None
namespace: Optional[str] = None
isFirstTime: Optional[bool] = None
offerId: Optional[str] = None
order: Optional[Any] = None
updated: Optional[datetime] = None
offer: Optional[CatalogOfferModel] = None
unmapped: Dict[str, Any] = field(default_factory=dict)
@classmethod
def from_dict(cls: Type["WishlistItemModel"], src: Dict[str, Any]) -> "WishlistItemModel":
d = src.copy()
created = parse_date(x) if (x := d.pop("created", "")) else None
offer = CatalogOfferModel.from_dict(x) if (x := d.pop("offer", {})) else None
updated = parse_date(x) if (x := d.pop("updated", "")) else None
tmp = cls(
created=created,
id=d.pop("id", ""),
namespace=d.pop("namespace", ""),
isFirstTime=d.pop("isFirstTime", None),
offerId=d.pop("offerId", ""),
order=d.pop("order", ""),
updated=updated,
offer=offer,
)
tmp.unmapped = d
return tmp
@dataclass
class PagingModel:
count: Optional[int] = None
total: Optional[int] = None
unmapped: Dict[str, Any] = field(default_factory=dict)
@classmethod
def from_dict(cls: Type["PagingModel"], src: Dict[str, Any]) -> "PagingModel":
d = src.copy()
count = d.pop("count", None)
total = d.pop("total", None)
tmp = cls(count=count, total=total)
tmp.unmapped = d
return tmp
@dataclass
class SearchStoreModel:
elements: Optional[List[CatalogOfferModel]] = None
paging: Optional[PagingModel] = None
unmapped: Dict[str, Any] = field(default_factory=dict)
@classmethod
def from_dict(cls: Type["SearchStoreModel"], src: Dict[str, Any]) -> "SearchStoreModel":
d = src.copy()
_elements = d.pop("elements", [])
elements = [] if _elements else None
for item in _elements:
elem = CatalogOfferModel.from_dict(item)
elements.append(elem)
paging = PagingModel.from_dict(x) if (x := d.pop("paging", {})) else None
tmp = cls(elements=elements, paging=paging)
tmp.unmapped = d
return tmp
@dataclass
class CatalogModel:
searchStore: Optional[SearchStoreModel] = None
unmapped: Dict[str, Any] = field(default_factory=dict)
@classmethod
def from_dict(cls: Type["CatalogModel"], src: Dict[str, Any]) -> "CatalogModel":
d = src.copy()
search_store = SearchStoreModel.from_dict(x) if (x := d.pop("searchStore", {})) else None
tmp = cls(searchStore=search_store)
tmp.unmapped = d
return tmp
@dataclass
class WishlistItemsModel:
elements: Optional[List[WishlistItemModel]] = None
paging: Optional[PagingModel] = None
unmapped: Dict[str, Any] = field(default_factory=dict)
@classmethod
def from_dict(cls: Type["WishlistItemsModel"], src: Dict[str, Any]) -> "WishlistItemsModel":
d = src.copy()
_elements = d.pop("elements", [])
elements = [] if _elements else None
for item in _elements:
elem = WishlistItemModel.from_dict(item)
elements.append(elem)
paging = PagingModel.from_dict(x) if (x := d.pop("paging", {})) else None
tmp = cls(elements=elements, paging=paging)
tmp.unmapped = d
return tmp
@dataclass
class RemoveFromWishlistModel:
success: Optional[bool] = None
unmapped: Dict[str, Any] = field(default_factory=dict)
@classmethod
def from_dict(cls: Type["RemoveFromWishlistModel"], src: Dict[str, Any]) -> "RemoveFromWishlistModel":
d = src.copy()
tmp = cls(success=d.pop("success", None))
tmp.unmapped = d
return tmp
@dataclass
class AddToWishlistModel:
wishlistItem: Optional[WishlistItemModel] = None
success: Optional[bool] = None
unmapped: Dict[str, Any] = field(default_factory=dict)
@classmethod
def from_dict(cls: Type["AddToWishlistModel"], src: Dict[str, Any]) -> "AddToWishlistModel":
d = src.copy()
wishlist_item = WishlistItemModel.from_dict(x) if (x := d.pop("wishlistItem", {})) else None
tmp = cls(wishlistItem=wishlist_item, success=d.pop("success", None))
tmp.unmapped = d
return tmp
@dataclass
class WishlistModel:
wishlistItems: Optional[WishlistItemsModel] = None
removeFromWishlist: Optional[RemoveFromWishlistModel] = None
addToWishlist: Optional[AddToWishlistModel] = None
unmapped: Dict[str, Any] = field(default_factory=dict)
@classmethod
def from_dict(cls: Type["WishlistModel"], src: Dict[str, Any]) -> "WishlistModel":
d = src.copy()
wishlist_items = WishlistItemsModel.from_dict(x) if (x := d.pop("wishlistItems", {})) else None
remove_from_wishlist = RemoveFromWishlistModel.from_dict(x) if (x := d.pop("removeFromWishlist", {})) else None
add_to_wishlist = AddToWishlistModel.from_dict(x) if (x := d.pop("addToWishlist", {})) else None
tmp = cls(
wishlistItems=wishlist_items, removeFromWishlist=remove_from_wishlist, addToWishlist=add_to_wishlist
)
tmp.unmapped = d
return tmp
ProductModel = Dict
@dataclass
class DataModel:
product: Optional[ProductModel] = None
catalog: Optional[CatalogModel] = None
wishlist: Optional[WishlistModel] = None
unmapped: Dict[str, Any] = field(default_factory=dict)
@classmethod
def from_dict(cls: Type["DataModel"], src: Dict[str, Any]) -> "DataModel":
d = src.copy()
catalog = CatalogModel.from_dict(x) if (x := d.pop("Catalog", {})) else None
wishlist = WishlistModel.from_dict(x) if (x := d.pop("Wishlist", {})) else None
tmp = cls(product=d.pop("Product", {}), catalog=catalog, wishlist=wishlist)
tmp.unmapped = d
return tmp
@dataclass
class ErrorModel:
unmapped: Dict[str, Any] = field(default_factory=dict)
@classmethod
def from_dict(cls: Type["ErrorModel"], src: Dict[str, Any]) -> "ErrorModel":
d = src.copy()
tmp = cls()
tmp.unmapped = d
return tmp
@dataclass
class ExtensionsModel:
unmapped: Dict[str, Any] = field(default_factory=dict)
@classmethod
def from_dict(cls: Type["ExtensionsModel"], src: Dict[str, Any]) -> "ExtensionsModel":
d = src.copy()
tmp = cls()
tmp.unmapped = d
return tmp
@dataclass
class ResponseModel:
data: Optional[DataModel] = None
errors: Optional[List[ErrorModel]] = None
extensions: Optional[ExtensionsModel] = None
unmapped: Dict[str, Any] = field(default_factory=dict)
@classmethod
def from_dict(cls: Type["ResponseModel"], src: Dict[str, Any]) -> "ResponseModel":
d = src.copy()
data = DataModel.from_dict(x) if (x := d.pop("data", {})) else None
_errors = d.pop("errors", [])
errors = [] if _errors else None
for item in _errors:
error = ErrorModel.from_dict(item)
errors.append(error)
extensions = ExtensionsModel.from_dict(x) if (x := d.pop("extensions", {})) else None
tmp = cls(data=data, errors=errors, extensions=extensions)
tmp.unmapped = d
return tmp

View file

@ -0,0 +1,5 @@
from datetime import datetime, timezone
def parse_date(date: str):
return datetime.fromisoformat(date[:-1]).replace(tzinfo=timezone.utc)

View file

@ -44,74 +44,411 @@ class Constants(QObject):
]
game_query = (
"query searchStoreQuery($allowCountries: String, $category: String, $count: Int, $country: String!, "
"$keywords: String, $locale: String, $namespace: String, $withMapping: Boolean = false, $itemNs: String, "
"$sortBy: String, $sortDir: String, $start: Int, $tag: String, $releaseDate: String, $withPrice: Boolean "
"= false, $withPromotions: Boolean = false, $priceRange: String, $freeGame: Boolean, $onSale: Boolean, "
"$effectiveDate: String) {\n Catalog {\n searchStore(\n allowCountries: $allowCountries\n "
"category: $category\n count: $count\n country: $country\n keywords: $keywords\n "
"locale: $locale\n namespace: $namespace\n itemNs: $itemNs\n sortBy: $sortBy\n "
"sortDir: $sortDir\n releaseDate: $releaseDate\n start: $start\n tag: $tag\n "
"priceRange: $priceRange\n freeGame: $freeGame\n onSale: $onSale\n effectiveDate: "
"$effectiveDate\n ) {\n elements {\n title\n id\n namespace\n "
"description\n effectiveDate\n keyImages {\n type\n url\n }\n "
" currentPrice\n seller {\n id\n name\n }\n productSlug\n "
" urlSlug\n url\n tags {\n id\n }\n items {\n id\n "
" namespace\n }\n customAttributes {\n key\n value\n }\n "
"categories {\n path\n }\n catalogNs @include(if: $withMapping) {\n "
'mappings(pageType: "productHome") {\n pageSlug\n pageType\n }\n '
"}\n offerMappings @include(if: $withMapping) {\n pageSlug\n pageType\n "
"}\n price(country: $country) @include(if: $withPrice) {\n totalPrice {\n "
"discountPrice\n originalPrice\n voucherDiscount\n discount\n "
" currencyCode\n currencyInfo {\n decimals\n }\n fmtPrice("
"locale: $locale) {\n originalPrice\n discountPrice\n "
"intermediatePrice\n }\n }\n lineOffers {\n appliedRules {\n "
" id\n endDate\n discountSetting {\n discountType\n "
" }\n }\n }\n }\n promotions(category: $category) @include(if: "
"$withPromotions) {\n promotionalOffers {\n promotionalOffers {\n "
"startDate\n endDate\n discountSetting {\n discountType\n "
" discountPercentage\n }\n }\n }\n "
"upcomingPromotionalOffers {\n promotionalOffers {\n startDate\n "
"endDate\n discountSetting {\n discountType\n "
"discountPercentage\n }\n }\n }\n }\n }\n paging {\n "
" count\n total\n }\n }\n }\n}\n "
)
__Image = '''
type
url
alt
'''
search_query = (
"query searchStoreQuery($allowCountries: String, $category: String, $count: Int, $country: String!, "
"$keywords: String, $locale: String, $namespace: String, $withMapping: Boolean = false, $itemNs: String, "
"$sortBy: String, $sortDir: String, $start: Int, $tag: String, $releaseDate: String, $withPrice: Boolean = "
"false, $withPromotions: Boolean = false, $priceRange: String, $freeGame: Boolean, $onSale: Boolean, "
"$effectiveDate: String) {\n Catalog {\n searchStore(\n allowCountries: $allowCountries\n "
"category: $category\n count: $count\n country: $country\n keywords: $keywords\n locale: "
"$locale\n namespace: $namespace\n itemNs: $itemNs\n sortBy: $sortBy\n sortDir: "
"$sortDir\n releaseDate: $releaseDate\n start: $start\n tag: $tag\n priceRange: "
"$priceRange\n freeGame: $freeGame\n onSale: $onSale\n effectiveDate: $effectiveDate\n ) {"
"\n elements {\n title\n id\n namespace\n description\n "
"effectiveDate\n keyImages {\n type\n url\n }\n currentPrice\n "
"seller {\n id\n name\n }\n productSlug\n urlSlug\n url\n "
" tags {\n id\n }\n items {\n id\n namespace\n }\n "
"customAttributes {\n key\n value\n }\n categories {\n path\n "
'}\n catalogNs @include(if: $withMapping) {\n mappings(pageType: "productHome") {\n '
" pageSlug\n pageType\n }\n }\n offerMappings @include(if: $withMapping) "
"{\n pageSlug\n pageType\n }\n price(country: $country) @include(if: "
"$withPrice) {\n totalPrice {\n discountPrice\n originalPrice\n "
"voucherDiscount\n discount\n currencyCode\n currencyInfo {\n "
"decimals\n }\n fmtPrice(locale: $locale) {\n originalPrice\n "
"discountPrice\n intermediatePrice\n }\n }\n lineOffers {\n "
" appliedRules {\n id\n endDate\n discountSetting {\n "
"discountType\n }\n }\n }\n }\n promotions(category: "
"$category) @include(if: $withPromotions) {\n promotionalOffers {\n promotionalOffers {\n "
" startDate\n endDate\n discountSetting {\n "
"discountType\n discountPercentage\n }\n }\n }\n "
"upcomingPromotionalOffers {\n promotionalOffers {\n startDate\n "
"endDate\n discountSetting {\n discountType\n discountPercentage\n "
" }\n }\n }\n }\n }\n paging {\n count\n "
"total\n }\n }\n }\n}\n "
)
__StorePageMapping = '''
cmsSlug
offerId
prePurchaseOfferId
'''
wishlist_query = '\n query wishlistQuery($country:String!, $locale:String) {\n Wishlist {\n wishlistItems {\n elements {\n id\n order\n created\n offerId\n updated\n namespace\n \n offer {\n productSlug\n urlSlug\n title\n id\n namespace\n offerType\n expiryDate\n status\n isCodeRedemptionOnly\n description\n effectiveDate\n keyImages {\n type\n url\n }\n seller {\n id\n name\n }\n productSlug\n urlSlug\n items {\n id\n namespace\n }\n customAttributes {\n key\n value\n }\n catalogNs {\n mappings(pageType: "productHome") {\n pageSlug\n pageType\n }\n }\n offerMappings {\n pageSlug\n pageType\n }\n categories {\n path\n }\n price(country: $country) {\n totalPrice {\n discountPrice\n originalPrice\n voucherDiscount\n discount\n fmtPrice(locale: $locale) {\n originalPrice\n discountPrice\n intermediatePrice\n }\n currencyCode\n currencyInfo {\n decimals\n symbol\n }\n }\n lineOffers {\n appliedRules {\n id\n endDate\n }\n }\n }\n }\n\n }\n }\n }\n }\n'
add_to_wishlist_query = "\n mutation removeFromWishlistMutation($namespace: String!, $offerId: String!, $operation: RemoveOperation!) {\n Wishlist {\n removeFromWishlist(namespace: $namespace, offerId: $offerId, operation: $operation) {\n success\n }\n }\n }\n"
remove_from_wishlist_query = "\n mutation removeFromWishlistMutation($namespace: String!, $offerId: String!, $operation: RemoveOperation!) {\n Wishlist {\n removeFromWishlist(namespace: $namespace, offerId: $offerId, operation: $operation) {\n success\n }\n }\n }\n"
coupon_query = "\n query getCoupons($currencyCountry: String!, $identityId: String!, $locale: String) {\n CodeRedemption {\n coupons(currencyCountry: $currencyCountry, identityId: $identityId, includeSalesEventInfo: true) {\n code\n codeStatus\n codeType\n consumptionMetadata {\n amountDisplay {\n amount\n currency\n placement\n symbol\n }\n minSalesPriceDisplay {\n amount\n currency\n placement\n symbol\n }\n }\n endDate\n namespace\n salesEvent(locale: $locale) {\n eventName\n eventSlug\n voucherImages {\n type\n url\n }\n voucherLink\n }\n startDate\n }\n }\n }\n"
__PageSandboxModel = '''
pageSlug
pageType
productId
sandboxId
createdDate
updatedDate
deletedDate
mappings {
%s
}
''' % (__StorePageMapping)
__CatalogNamespace = '''
parent
displayName
store
home: mappings(pageType: "productHome") {
%s
}
addons: mappings(pageType: "addon--cms-hybrid") {
%s
}
offers: mappings(pageType: "offer") {
%s
}
''' % (__PageSandboxModel, __PageSandboxModel, __PageSandboxModel)
__CatalogItem = '''
id
namespace
'''
__GetPriceRes = '''
totalPrice {
discountPrice
originalPrice
voucherDiscount
discount
currencyCode
currencyInfo {
decimals
symbol
}
fmtPrice(locale: $locale) {
originalPrice
discountPrice
intermediatePrice
}
}
lineOffers {
appliedRules {
id
endDate
discountSetting {
discountType
}
}
}
'''
__Promotions = '''
promotionalOffers {
promotionalOffers {
startDate
endDate
discountSetting {
discountType
discountPercentage
}
}
}
upcomingPromotionalOffers {
promotionalOffers {
startDate
endDate
discountSetting {
discountType
discountPercentage
}
}
}
'''
__CatalogOffer = '''
title
id
namespace
offerType
expiryDate
status
isCodeRedemptionOnly
description
effectiveDate
keyImages {
%(image)s
}
currentPrice
seller {
id
name
}
productSlug
urlSlug
url
tags {
id
name
groupName
}
items {
%(catalog_item)s
}
customAttributes {
key
value
}
categories {
path
}
catalogNs @include(if: $withMapping) {
%(catalog_namespace)s
}
offerMappings @include(if: $withMapping) {
%(page_sandbox_model)s
}
price(country: $country) @include(if: $withPrice) {
%(get_price_res)s
}
promotions(category: $category) @include(if: $withPromotions) {
%(promotions)s
}
''' % {
"image": __Image,
"catalog_item": __CatalogItem,
"catalog_namespace": __CatalogNamespace,
"page_sandbox_model": __PageSandboxModel,
"get_price_res": __GetPriceRes,
"promotions": __Promotions,
}
__Pagination = '''
count
total
'''
SEARCH_STORE_QUERY = '''
query searchStoreQuery(
$allowCountries: String
$category: String
$count: Int
$country: String!
$keywords: String
$locale: String
$namespace: String
$withMapping: Boolean = false
$itemNs: String
$sortBy: String
$sortDir: String
$start: Int
$tag: String
$releaseDate: String
$withPrice: Boolean = false
$withPromotions: Boolean = false
$priceRange: String
$freeGame: Boolean
$onSale: Boolean
$effectiveDate: String
) {
Catalog {
searchStore(
allowCountries: $allowCountries
category: $category
count: $count
country: $country
keywords: $keywords
locale: $locale
namespace: $namespace
itemNs: $itemNs
sortBy: $sortBy
sortDir: $sortDir
releaseDate: $releaseDate
start: $start
tag: $tag
priceRange: $priceRange
freeGame: $freeGame
onSale: $onSale
effectiveDate: $effectiveDate
) {
elements {
%s
}
paging {
%s
}
}
}
}
''' % (__CatalogOffer, __Pagination)
__WISHLIST_ITEM = '''
id
order
created
offerId
updated
namespace
isFirstTime
offer(locale: $locale) {
%s
}
''' % __CatalogOffer
WISHLIST_QUERY = '''
query wishlistQuery(
$country: String!
$locale: String
$category: String
$withMapping: Boolean = false
$withPrice: Boolean = false
$withPromotions: Boolean = false
) {
Wishlist {
wishlistItems {
elements {
%s
}
}
}
}
''' % __WISHLIST_ITEM
WISHLIST_ADD_QUERY = '''
mutation addWishlistMutation(
$namespace: String!
$offerId: String!
$country: String!
$locale: String
$category: String
$withMapping: Boolean = false
$withPrice: Boolean = false
$withPromotions: Boolean = false
) {
Wishlist {
addToWishlist(
namespace: $namespace
offerId: $offerId
) {
wishlistItem {
%s
}
success
}
}
}
''' % __WISHLIST_ITEM
WISHLIST_REMOVE_QUERY = '''
mutation removeFromWishlistMutation(
$namespace: String!
$offerId: String!
$operation: RemoveOperation!
) {
Wishlist {
removeFromWishlist(
namespace: $namespace
offerId: $offerId
operation: $operation
) {
success
}
}
}
'''
COUPONS_QUERY = '''
query getCoupons(
$currencyCountry: String!
$identityId: String!
$locale: String
) {
CodeRedemption {
coupons(
currencyCountry: $currencyCountry
identityId: $identityId
includeSalesEventInfo: true
) {
code
codeStatus
codeType
consumptionMetadata {
amountDisplay {
amount
currency
placement
symbol
}
minSalesPriceDisplay {
amount
currency
placement
symbol
}
}
endDate
namespace
salesEvent(locale: $locale) {
eventName
eventSlug
voucherImages {
type
url
}
voucherLink
}
startDate
}
}
}
'''
STORE_CONFIG_QUERY = '''
query getStoreConfig(
$includeCriticReviews: Boolean = false
$locale: String!
$sandboxId: String!
$templateId: String
) {
Product {
sandbox(sandboxId: $sandboxId) {
configuration(locale: $locale, templateId: $templateId) {
... on StoreConfiguration {
configs {
shortDescription
criticReviews @include(if: $includeCriticReviews) {
openCritic
}
socialLinks {
platform
url
}
supportedAudio
supportedText
tags(locale: $locale) {
id
name
groupName
}
technicalRequirements {
macos {
minimum
recommended
title
}
windows {
minimum
recommended
title
}
}
}
}
... on HomeConfiguration {
configs {
keyImages {
... on KeyImage {
type
url
alt
}
}
longDescription
}
}
}
}
}
}
'''
def compress_query(query: str) -> str:
return query.replace(" ", "").replace("\n", " ")
game_query = compress_query(SEARCH_STORE_QUERY)
search_query = compress_query(SEARCH_STORE_QUERY)
wishlist_query = compress_query(WISHLIST_QUERY)
wishlist_add_query = compress_query(WISHLIST_ADD_QUERY)
wishlist_remove_query = compress_query(WISHLIST_REMOVE_QUERY)
coupons_query = compress_query(COUPONS_QUERY)
store_config_query = compress_query(STORE_CONFIG_QUERY)
if __name__ == "__main__":
print(SEARCH_STORE_QUERY)

View file

@ -1,271 +0,0 @@
import logging
from PyQt5.QtCore import Qt, QUrl
from PyQt5.QtGui import QPixmap, QFont, QDesktopServices
from PyQt5.QtWidgets import (
QWidget,
QLabel,
QPushButton,
QHBoxLayout,
QSpacerItem,
QGroupBox,
QTabWidget,
QGridLayout,
)
from rare.components.tabs.store.shop_models import ShopGame
from rare.shared import LegendaryCoreSingleton
from rare.ui.components.tabs.store.shop_game_info import Ui_shop_info
from rare.utils.extra_widgets import ImageLabel
from rare.utils.misc import icon
from rare.widgets.loading_widget import LoadingWidget
logger = logging.getLogger("ShopInfo")
class ShopGameInfo(QWidget, Ui_shop_info):
game: ShopGame
data: dict
# TODO Design
def __init__(self, installed_titles: list, api_core):
super(ShopGameInfo, self).__init__()
self.setupUi(self)
self.core = LegendaryCoreSingleton()
self.api_core = api_core
self.installed = installed_titles
self.open_store_button.clicked.connect(self.button_clicked)
self.image = ImageLabel()
self.image_stack.addWidget(self.image)
self.image_stack.addWidget(LoadingWidget())
warn_label = QLabel()
warn_label.setPixmap(
icon("fa.warning").pixmap(160, 160).scaled(240, 320, Qt.IgnoreAspectRatio)
)
self.image_stack.addWidget(warn_label)
self.wishlist_button.clicked.connect(self.add_to_wishlist)
self.in_wishlist = False
self.wishlist = []
def handle_wishlist_update(self, data):
if data and data[0] == "error":
return
self.wishlist = [i["offer"]["title"] for i in data]
if self.title_str in self.wishlist:
self.in_wishlist = True
self.wishlist_button.setVisible(True)
self.wishlist_button.setText(self.tr("Remove from Wishlist"))
else:
self.in_wishlist = False
self.wishlist_button.setVisible(False)
def update_game(self, data: dict):
self.image_stack.setCurrentIndex(1)
self.title.setText(data["title"])
self.title_str = data["title"]
self.api_core.get_wishlist(self.handle_wishlist_update)
for i in reversed(range(self.req_group_box.layout().count())):
self.req_group_box.layout().itemAt(i).widget().deleteLater()
slug = data["productSlug"]
if not slug:
for mapping in data["offerMappings"]:
if mapping["pageType"] == "productHome":
slug = mapping["pageSlug"]
break
else:
logger.error("Could not get page information")
slug = ""
if "/home" in slug:
slug = slug.replace("/home", "")
self.slug = slug
if data["namespace"] in self.installed:
self.open_store_button.setText(self.tr("Show Game on Epic Page"))
self.owned_label.setVisible(True)
else:
self.open_store_button.setText(self.tr("Buy Game in Epic Games Store"))
self.owned_label.setVisible(False)
for i in range(self.req_group_box.layout().count()):
self.req_group_box.layout().itemAt(i).widget().deleteLater()
self.price.setText(self.tr("Loading"))
self.wishlist_button.setVisible(False)
# self.title.setText(self.tr("Loading"))
self.image.setPixmap(QPixmap())
self.data = data
is_bundle = False
for i in data["categories"]:
if "bundles" in i.get("path", ""):
is_bundle = True
# init API request
if slug:
self.api_core.get_game(slug, is_bundle, self.data_received)
else:
self.data_received({})
def add_to_wishlist(self):
if not self.in_wishlist:
return
# self.api_core.add_to_wishlist(self.game.namespace, self.game.offer_id,
# lambda success: self.wishlist_button.setText(self.tr("Remove from wishlist"))
# if success else self.wishlist_button.setText("Something goes wrong"))
else:
self.api_core.remove_from_wishlist(
self.game.namespace,
self.game.offer_id,
lambda success: self.wishlist_button.setVisible(False)
if success
else self.wishlist_button.setText("Something goes wrong"),
)
def data_received(self, game):
try:
self.game = ShopGame.from_json(game, self.data)
except Exception as e:
logger.error(str(e))
self.price.setText("Error")
self.req_group_box.setVisible(False)
for img in self.data.get("keyImages"):
if img["type"] in [
"DieselStoreFrontWide",
"OfferImageTall",
"VaultClosed",
"ProductLogo",
]:
self.image.update_image(img["url"], self.title_str, size=(240, 320))
self.image_stack.setCurrentIndex(0)
break
else:
self.image_stack.setCurrentIndex(2)
self.price.setText("")
self.discount_price.setText("")
self.social_link_gb.setVisible(False)
self.tags.setText("")
self.dev.setText(self.data.get("seller", {}).get("name", ""))
return
self.title.setText(self.game.title)
self.price.setFont(QFont())
if self.game.price == "0" or self.game.price == 0:
self.price.setText(self.tr("Free"))
else:
self.price.setText(self.game.price)
if self.game.price != self.game.discount_price:
font = QFont()
font.setStrikeOut(True)
self.price.setFont(font)
self.discount_price.setText(
self.game.discount_price
if self.game.discount_price != "0"
else self.tr("Free")
)
self.discount_price.setVisible(True)
else:
self.discount_price.setVisible(False)
bold_font = QFont()
bold_font.setBold(True)
if self.game.reqs:
req_tabs = QTabWidget()
for system in self.game.reqs:
min_label = QLabel(self.tr("Minimum"))
min_label.setFont(bold_font)
rec_label = QLabel(self.tr("Recommend"))
rec_label.setFont(bold_font)
req_widget = QWidget()
req_widget.setLayout(QGridLayout())
req_widget.layout().addWidget(min_label, 0, 1)
req_widget.layout().addWidget(rec_label, 0, 2)
for i, (key, value) in enumerate(
self.game.reqs.get(system, {}).items()
):
req_widget.layout().addWidget(QLabel(key), i + 1, 0)
min_label = QLabel(value[0])
min_label.setWordWrap(True)
req_widget.layout().addWidget(min_label, i + 1, 1)
rec_label = QLabel(value[1])
rec_label.setWordWrap(True)
req_widget.layout().addWidget(rec_label, i + 1, 2)
req_tabs.addTab(req_widget, system)
self.req_group_box.layout().addWidget(req_tabs)
else:
self.req_group_box.layout().addWidget(
QLabel(self.tr("Could not get requirements"))
)
self.req_group_box.setVisible(True)
if self.game.image_urls.front_tall:
img_url = self.game.image_urls.front_tall
elif self.game.image_urls.offer_image_tall:
img_url = self.game.image_urls.offer_image_tall
elif self.game.image_urls.product_logo:
img_url = self.game.image_urls.product_logo
else:
img_url = ""
self.image.update_image(img_url, self.game.title, (240, 320))
self.image_stack.setCurrentIndex(0)
try:
if isinstance(self.game.developer, list):
self.dev.setText(", ".join(self.game.developer))
else:
self.dev.setText(self.game.developer)
except KeyError:
pass
self.tags.setText(", ".join(self.game.tags))
# clear Layout
for widget in (
self.social_link_gb.layout().itemAt(i)
for i in range(self.social_link_gb.layout().count())
):
if not isinstance(widget, QSpacerItem):
widget.widget().deleteLater()
self.social_link_gb.deleteLater()
self.social_link_gb = QGroupBox(self.tr("Social Links"))
self.social_link_gb.setLayout(QHBoxLayout())
self.layout().insertWidget(3, self.social_link_gb)
self.social_link_gb.layout().addStretch(1)
link_count = 0
for name, url in self.game.links:
if name.lower() == "homepage":
icn = icon("mdi.web", "fa.search", scale_factor=1.5)
else:
try:
icn = icon(f"mdi.{name.lower()}", f"fa.{name.lower()}", scale_factor=1.5)
except Exception as e:
logger.error(str(e))
continue
button = SocialButton(icn, url)
self.social_link_gb.layout().addWidget(button)
link_count += 1
self.social_link_gb.layout().addStretch(1)
if link_count == 0:
self.social_link_gb.setVisible(False)
else:
self.social_link_gb.setVisible(True)
self.social_link_gb.layout().addStretch(1)
def add_wishlist_items(self, wishlist):
wishlist = wishlist["data"]["Wishlist"]["wishlistItems"]["elements"]
for game in wishlist:
self.wishlist.append(game["offer"]["title"])
def button_clicked(self):
QDesktopServices.openUrl(QUrl(f"https://www.epicgames.com/store/{self.core.language_code}/p/{self.slug}"))
class SocialButton(QPushButton):
def __init__(self, icn, url):
super(SocialButton, self).__init__(icn, "")
self.url = url
self.clicked.connect(lambda: QDesktopServices.openUrl(QUrl(url)))
self.setToolTip(url)

View file

@ -1,142 +0,0 @@
import logging
from PyQt5 import QtGui
from PyQt5.QtCore import pyqtSignal
from PyQt5.QtGui import QFont
from PyQt5.QtNetwork import QNetworkAccessManager
from PyQt5.QtWidgets import QWidget, QVBoxLayout, QLabel, QHBoxLayout
from rare.components.tabs.store.shop_models import ImageUrlModel
from rare.ui.components.tabs.store.wishlist_widget import Ui_WishlistWidget
from rare.utils.extra_widgets import ImageLabel
from rare.utils.misc import icon
logger = logging.getLogger("GameWidgets")
class GameWidget(QWidget):
show_info = pyqtSignal(dict)
def __init__(self, path, json_info=None, width=300):
super(GameWidget, self).__init__()
self.manager = QNetworkAccessManager()
self.width = width
self.path = path
if json_info:
self.init_ui(json_info)
def init_ui(self, json_info):
self.layout = QVBoxLayout()
self.image = ImageLabel()
self.layout.addWidget(self.image)
mini_layout = QHBoxLayout()
self.layout.addLayout(mini_layout)
if not json_info:
self.layout.addWidget(QLabel("An error occurred"))
self.setLayout(self.layout)
return
self.title_label = QLabel(json_info.get("title"))
self.title_label.setWordWrap(True)
mini_layout.addWidget(self.title_label)
mini_layout.addStretch(1)
price = json_info["price"]["totalPrice"]["fmtPrice"]["originalPrice"]
discount_price = json_info["price"]["totalPrice"]["fmtPrice"]["discountPrice"]
price_label = QLabel(price)
if price != discount_price:
font = QFont()
font.setStrikeOut(True)
price_label.setFont(font)
mini_layout.addWidget(
QLabel(discount_price if discount_price != "0" else self.tr("Free"))
)
mini_layout.addWidget(price_label)
else:
if price == "0":
price_label.setText(self.tr("Free"))
mini_layout.addWidget(price_label)
for c in r'<>?":|\/*':
json_info["title"] = json_info["title"].replace(c, "")
self.json_info = json_info
self.slug = json_info["productSlug"]
self.title = json_info["title"]
for img in json_info["keyImages"]:
if img["type"] in [
"DieselStoreFrontWide",
"OfferImageWide",
"VaultClosed",
"ProductLogo",
]:
if img["type"] == "VaultClosed" and self.title != "Mystery Game":
continue
self.image.update_image(
img["url"],
json_info["title"],
(self.width, int(self.width * 9 / 16)),
)
break
else:
logger.info(", ".join([img["type"] for img in json_info["keyImages"]]))
self.setLayout(self.layout)
self.setFixedSize(self.width + 10, self.width * 9 // 16 + 50)
def mousePressEvent(self, a0: QtGui.QMouseEvent) -> None:
self.show_info.emit(self.json_info)
class WishlistWidget(QWidget, Ui_WishlistWidget):
open_game = pyqtSignal(dict)
delete_from_wishlist = pyqtSignal(dict)
def __init__(self, game: dict):
super(WishlistWidget, self).__init__()
self.setupUi(self)
self.game = game
self.title_label.setText(game.get("title"))
for attr in game["customAttributes"]:
if attr["key"] == "developerName":
self.developer.setText(attr["value"])
break
else:
self.developer.setText(game["seller"]["name"])
original_price = game["price"]["totalPrice"]["fmtPrice"]["originalPrice"]
discount_price = game["price"]["totalPrice"]["fmtPrice"]["discountPrice"]
self.price.setText(original_price if original_price != "0" else self.tr("Free"))
# if discount
if original_price != discount_price:
self.discount = True
font = QFont()
font.setStrikeOut(True)
self.price.setFont(font)
self.discount_price.setText(discount_price)
else:
self.discount = False
self.discount_price.setVisible(False)
self.image = ImageLabel()
self.layout().insertWidget(0, self.image)
image_model = ImageUrlModel.from_json(game["keyImages"])
url = image_model.front_wide
if not url:
url = image_model.offer_image_wide
self.image.update_image(url, game.get("title"), (240, 135))
self.delete_button.setIcon(icon("mdi.delete", color="white"))
self.delete_button.clicked.connect(
lambda: self.delete_from_wishlist.emit(self.game)
)
def mousePressEvent(self, e: QtGui.QMouseEvent) -> None:
# left button
if e.button() == 1:
self.open_game.emit(self.game)
# right
elif e.button() == 2:
pass # self.showMenu(e)

View file

@ -0,0 +1,239 @@
import datetime
import logging
from typing import List
from PyQt5.QtCore import Qt, pyqtSlot, pyqtSignal, QObject, QEvent
from PyQt5.QtGui import QShowEvent, QHideEvent, QResizeEvent
from PyQt5.QtWidgets import (
QHBoxLayout,
QWidget,
QSizePolicy,
QVBoxLayout,
QSpacerItem,
QScrollArea,
QFrame,
)
from rare.components.tabs.store.api.models.response import CatalogOfferModel, WishlistItemModel
from rare.widgets.flow_layout import FlowLayout
from rare.widgets.side_tab import SideTabContents
from rare.widgets.sliding_stack import SlidingStackedWidget
from .store_api import StoreAPI
from .widgets.details import StoreDetailsWidget
from .widgets.groups import StoreGroup
from .widgets.items import StoreItemWidget
logger = logging.getLogger("StoreLanding")
class LandingPage(SlidingStackedWidget, SideTabContents):
def __init__(self, store_api: StoreAPI, parent=None):
super(LandingPage, self).__init__(parent=parent)
self.implements_scrollarea = True
self.landing_widget = LandingWidget(store_api, parent=self)
self.landing_widget.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding)
self.landing_widget.set_title.connect(self.set_title)
self.landing_widget.show_details.connect(self.show_details)
self.landing_scroll = QScrollArea(self)
self.landing_scroll.setWidgetResizable(True)
self.landing_scroll.setFrameStyle(QFrame.NoFrame | QFrame.Plain)
self.landing_scroll.setWidget(self.landing_widget)
self.landing_scroll.widget().setAutoFillBackground(False)
self.landing_scroll.viewport().setAutoFillBackground(False)
self.details_widget = StoreDetailsWidget([], store_api, parent=self)
self.details_widget.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding)
self.details_widget.set_title.connect(self.set_title)
self.details_widget.back_clicked.connect(self.show_main)
self.setDirection(Qt.Horizontal)
self.addWidget(self.landing_scroll)
self.addWidget(self.details_widget)
@pyqtSlot()
def show_main(self):
self.slideInWidget(self.landing_scroll)
@pyqtSlot(object)
def show_details(self, game: CatalogOfferModel):
self.details_widget.update_game(game)
self.slideInWidget(self.details_widget)
class FreeGamesScroll(QScrollArea):
def __init__(self, parent=None):
super(FreeGamesScroll, self).__init__(parent=parent)
self.setObjectName(type(self).__name__)
def setWidget(self, w):
super().setWidget(w)
w.installEventFilter(self)
def eventFilter(self, a0: QObject, a1: QEvent) -> bool:
if a0 is self.widget() and a1.type() == QEvent.Resize:
self.__resize(a0)
return a0.event(a1)
return False
def __resize(self, e: QResizeEvent):
minh = self.horizontalScrollBar().minimum()
maxh = self.horizontalScrollBar().maximum()
# lk: when the scrollbar is not visible, min and max are 0
if maxh > minh:
height = (
e.size().height()
+ self.rect().height() // 2
- self.contentsRect().height() // 2
+ self.widget().layout().spacing()
+ self.horizontalScrollBar().sizeHint().height()
)
else:
height = e.size().height() + self.rect().height() - self.contentsRect().height()
self.setMinimumHeight(max(height, self.minimumHeight()))
class LandingWidget(QWidget, SideTabContents):
show_details = pyqtSignal(CatalogOfferModel)
def __init__(self, api: StoreAPI, parent=None):
super(LandingWidget, self).__init__(parent=parent)
self.api = api
layout = QVBoxLayout(self)
layout.setContentsMargins(0, 0, 3, 0)
self.setLayout(layout)
self.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed)
self.free_games_now = StoreGroup(self.tr("Free now"), layout=QHBoxLayout, parent=self)
self.free_games_now.main_layout.setAlignment(Qt.AlignLeft | Qt.AlignVCenter)
self.free_games_now.setSizePolicy(QSizePolicy.Fixed, QSizePolicy.Fixed)
self.free_games_next = StoreGroup(self.tr("Free next week"), layout=QHBoxLayout, parent=self)
self.free_games_next.main_layout.setAlignment(Qt.AlignLeft | Qt.AlignVCenter)
self.free_games_next.setSizePolicy(QSizePolicy.Fixed, QSizePolicy.Fixed)
self.discounts_group = StoreGroup(self.tr("Wishlist discounts"), layout=FlowLayout, parent=self)
self.discounts_group.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding)
self.games_group = StoreGroup(self.tr("Free to play"), FlowLayout, self)
self.games_group.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding)
self.games_group.loading(False)
self.games_group.setVisible(False)
free_scroll = FreeGamesScroll(self)
free_container = QWidget(free_scroll)
free_scroll.setWidget(free_container)
free_container_layout = QHBoxLayout(free_container)
free_scroll.setWidgetResizable(True)
free_scroll.setFrameShape(QScrollArea.NoFrame)
free_scroll.setSizeAdjustPolicy(QScrollArea.AdjustToContents)
free_scroll.setVerticalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
free_container_layout.setContentsMargins(0, 0, 0, 0)
free_container_layout.setAlignment(Qt.AlignLeft | Qt.AlignTop)
free_container_layout.setSizeConstraint(QHBoxLayout.SetFixedSize)
free_container_layout.addWidget(self.free_games_now)
free_container_layout.addWidget(self.free_games_next)
free_scroll.widget().setAutoFillBackground(False)
free_scroll.viewport().setAutoFillBackground(False)
# layout.addWidget(self.free_games_now, alignment=Qt.AlignTop)
# layout.addWidget(self.free_games_next, alignment=Qt.AlignTop)
layout.addWidget(free_scroll, alignment=Qt.AlignTop)
layout.addWidget(self.discounts_group, alignment=Qt.AlignTop)
layout.addWidget(self.games_group, alignment=Qt.AlignTop)
layout.addItem(QSpacerItem(0, 0, QSizePolicy.Fixed, QSizePolicy.Expanding))
def showEvent(self, a0: QShowEvent) -> None:
if a0.spontaneous():
return super().showEvent(a0)
self.api.get_free(self.__update_free_games)
self.api.get_wishlist(self.__update_wishlist_discounts)
return super().showEvent(a0)
def hideEvent(self, a0: QHideEvent) -> None:
if a0.spontaneous():
return super().hideEvent(a0)
# TODO: Implement tab unloading
return super().hideEvent(a0)
def __update_wishlist_discounts(self, wishlist: List[WishlistItemModel]):
for w in self.discounts_group.findChildren(StoreItemWidget, options=Qt.FindDirectChildrenOnly):
self.discounts_group.layout().removeWidget(w)
w.deleteLater()
for item in filter(lambda x: bool(x.offer.price.totalPrice.discount), wishlist):
w = StoreItemWidget(self.api.cached_manager, item.offer)
w.show_details.connect(self.show_details)
self.discounts_group.layout().addWidget(w)
have_discounts = any(map(lambda x: bool(x.offer.price.totalPrice.discount), wishlist))
self.discounts_group.setVisible(have_discounts)
self.discounts_group.loading(False)
def __update_free_games(self, free_games: List[CatalogOfferModel]):
for w in self.free_games_now.findChildren(StoreItemWidget, options=Qt.FindDirectChildrenOnly):
self.free_games_now.layout().removeWidget(w)
w.deleteLater()
for w in self.free_games_next.findChildren(StoreItemWidget, options=Qt.FindDirectChildrenOnly):
self.free_games_next.layout().removeWidget(w)
w.deleteLater()
date = datetime.datetime.utcnow().replace(tzinfo=datetime.timezone.utc)
free_now = []
free_next = []
for item in free_games:
try:
if item.price.totalPrice.discountPrice == 0:
free_now.append(item)
continue
if item.title == "Mystery Game":
free_next.append(item)
continue
except KeyError as e:
logger.warning(str(e))
if item.promotions is not None:
if not item.promotions.promotionalOffers:
start_date = item.promotions.upcomingPromotionalOffers[0].promotionalOffers[0].startDate
else:
start_date = item.promotions.promotionalOffers[0].promotionalOffers[0].startDate
if start_date > date:
free_next.append(item)
# free games now
self.free_games_now.setVisible(bool(free_now))
for item in free_now:
w = StoreItemWidget(self.api.cached_manager, item)
w.show_details.connect(self.show_details)
self.free_games_now.layout().addWidget(w)
self.free_games_now.loading(False)
# free games next week
self.free_games_next.setVisible(bool(free_next))
for item in free_next:
w = StoreItemWidget(self.api.cached_manager, item)
if item.title != "Mystery Game":
w.show_details.connect(self.show_details)
self.free_games_next.layout().addWidget(w)
self.free_games_next.loading(False)
def show_games(self, data):
if not data:
return
for w in self.games_group.findChildren(StoreItemWidget, options=Qt.FindDirectChildrenOnly):
self.games_group.layout().removeWidget(w)
w.deleteLater()
for game in data:
w = StoreItemWidget(self.api.cached_manager, game)
w.show_details.connect(self.show_details)
self.games_group.layout().addWidget(w)
self.games_group.loading(False)

View file

@ -0,0 +1,56 @@
from PyQt5.QtCore import Qt
from PyQt5.QtCore import pyqtSignal
from PyQt5.QtWidgets import (
QWidget,
QSizePolicy,
QLabel,
QScrollArea,
)
from rare.widgets.flow_layout import FlowLayout
from .api.models.response import CatalogOfferModel
from .widgets.items import ResultsItemWidget
class ResultsWidget(QScrollArea):
show_details = pyqtSignal(CatalogOfferModel)
def __init__(self, store_api, parent=None):
super(ResultsWidget, self).__init__(parent=parent)
self.store_api = store_api
self.results_container = QWidget(self)
self.results_container.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed)
self.results_layout = FlowLayout(self.results_container)
self.setWidget(self.results_container)
self.setWidgetResizable(True)
# self.main_layout = QVBoxLayout(self)
# self.main_layout.setContentsMargins(0, 0, 0, 0)
# self.main_layout.addWidget(self.results_scrollarea)
self.setEnabled(False)
def load_results(self, text: str):
self.setEnabled(False)
if text != "":
self.store_api.search_game(text, self.show_results)
def show_results(self, results: dict):
for w in self.results_container.findChildren(QLabel, options=Qt.FindDirectChildrenOnly):
self.results_layout.removeWidget(w)
w.deleteLater()
for w in self.results_container.findChildren(ResultsItemWidget, options=Qt.FindDirectChildrenOnly):
self.results_layout.removeWidget(w)
w.deleteLater()
if not results:
self.results_layout.addWidget(QLabel(self.tr("No results found")))
else:
for res in results:
w = ResultsItemWidget(self.store_api.cached_manager, res, parent=self.results_container)
w.show_details.connect(self.show_details.emit)
self.results_layout.addWidget(w)
self.results_layout.update()
self.setEnabled(True)

View file

@ -0,0 +1,219 @@
import logging
from typing import List
from PyQt5.QtCore import pyqtSignal, Qt, pyqtSlot
from PyQt5.QtWidgets import (
QCheckBox,
QWidget,
QSizePolicy,
QScrollArea,
QFrame,
)
from legendary.core import LegendaryCore
from rare.ui.components.tabs.store.search import Ui_SearchWidget
from rare.utils.extra_widgets import ButtonLineEdit
from rare.widgets.side_tab import SideTabContents
from rare.widgets.sliding_stack import SlidingStackedWidget
from .api.models.query import SearchStoreQuery
from .api.models.response import CatalogOfferModel
from .constants import Constants
from .results import ResultsWidget
from .store_api import StoreAPI
from .widgets.details import StoreDetailsWidget
logger = logging.getLogger("Shop")
class SearchPage(SlidingStackedWidget, SideTabContents):
def __init__(self, store_api: StoreAPI, parent=None):
super(SearchPage, self).__init__(parent=parent)
self.implements_scrollarea = True
self.search_widget = SearchWidget(store_api, parent=self)
self.search_widget.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed)
self.search_widget.set_title.connect(self.set_title)
self.search_widget.show_details.connect(self.show_details)
self.details_widget = StoreDetailsWidget([], store_api, parent=self)
self.details_widget.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed)
self.details_widget.set_title.connect(self.set_title)
self.details_widget.back_clicked.connect(self.show_main)
self.setDirection(Qt.Horizontal)
self.addWidget(self.search_widget)
self.addWidget(self.details_widget)
@pyqtSlot()
def show_main(self):
self.slideInWidget(self.search_widget)
@pyqtSlot(object)
def show_details(self, game: CatalogOfferModel):
self.details_widget.update_game(game)
self.slideInWidget(self.details_widget)
# noinspection PyAttributeOutsideInit,PyBroadException
class SearchWidget(QWidget, SideTabContents):
show_details = pyqtSignal(CatalogOfferModel)
def __init__(self, store_api: StoreAPI, parent=None):
super(SearchWidget, self).__init__(parent=parent)
self.implements_scrollarea = True
self.ui = Ui_SearchWidget()
self.ui.setupUi(self)
self.ui.main_layout.setContentsMargins(0, 0, 3, 0)
self.ui.filter_scrollarea.widget().setAutoFillBackground(False)
self.ui.filter_scrollarea.viewport().setAutoFillBackground(False)
self.store_api = store_api
self.price = ""
self.tags = []
self.types = []
self.update_games_allowed = True
self.active_search_request = False
self.next_search = ""
self.wishlist: List = []
self.search_bar = ButtonLineEdit("fa.search", placeholder_text=self.tr("Search"))
self.results_scrollarea = ResultsWidget(self.store_api, self)
self.results_scrollarea.show_details.connect(self.show_details)
self.ui.left_layout.addWidget(self.search_bar)
self.ui.left_layout.addWidget(self.results_scrollarea)
self.search_bar.returnPressed.connect(self.show_search_results)
self.search_bar.buttonClicked.connect(self.show_search_results)
# self.init_filter()
def load(self):
# load browse games
self.prepare_request()
def show_search_results(self):
if text := self.search_bar.text():
self.results_scrollarea.load_results(text)
# self.show_info.emit(self.search_bar.text())
def init_filter(self):
self.ui.none_price.toggled.connect(
lambda: self.prepare_request("") if self.ui.none_price.isChecked() else None
)
self.ui.free_button.toggled.connect(
lambda: self.prepare_request("free") if self.ui.free_button.isChecked() else None
)
self.ui.under10.toggled.connect(
lambda: self.prepare_request("<price>[0, 1000)") if self.ui.under10.isChecked() else None
)
self.ui.under20.toggled.connect(
lambda: self.prepare_request("<price>[0, 2000)") if self.ui.under20.isChecked() else None
)
self.ui.under30.toggled.connect(
lambda: self.prepare_request("<price>[0, 3000)") if self.ui.under30.isChecked() else None
)
self.ui.above.toggled.connect(
lambda: self.prepare_request("<price>[1499,]") if self.ui.above.isChecked() else None
)
# self.on_discount.toggled.connect(lambda: self.prepare_request("sale") if self.on_discount.isChecked() else None)
self.ui.on_discount.toggled.connect(lambda: self.prepare_request())
constants = Constants()
self.checkboxes = []
for groupbox, variables in [
(self.ui.genre_group, constants.categories),
(self.ui.platform_group, constants.platforms),
(self.ui.others_group, constants.others),
(self.ui.type_group, constants.types),
]:
for text, tag in variables:
checkbox = CheckBox(text, tag)
checkbox.activated.connect(lambda x: self.prepare_request(added_tag=x))
checkbox.deactivated.connect(lambda x: self.prepare_request(removed_tag=x))
groupbox.layout().addWidget(checkbox)
self.checkboxes.append(checkbox)
self.ui.reset_button.clicked.connect(self.reset_filters)
self.ui.filter_scrollarea.setMinimumWidth(
self.ui.filter_container.sizeHint().width()
+ self.ui.filter_container.layout().contentsMargins().left()
+ self.ui.filter_container.layout().contentsMargins().right()
+ self.ui.filter_scrollarea.verticalScrollBar().sizeHint().width()
)
def reset_filters(self):
self.update_games_allowed = False
for cb in self.checkboxes:
cb.setChecked(False)
self.ui.none_price.setChecked(True)
self.tags = []
self.types = []
self.update_games_allowed = True
self.ui.on_discount.setChecked(False)
def prepare_request(
self,
price: str = None,
added_tag: int = 0,
removed_tag: int = 0,
added_type: str = "",
removed_type: str = "",
):
if not self.update_games_allowed:
return
if price is not None:
self.price = price
if added_tag != 0:
self.tags.append(added_tag)
if removed_tag != 0 and removed_tag in self.tags:
self.tags.remove(removed_tag)
if added_type:
self.types.append(added_type)
if removed_type and removed_type in self.types:
self.types.remove(removed_type)
if (self.types or self.price) or self.tags or self.ui.on_discount.isChecked():
# self.free_scrollarea.setVisible(False)
self.discounts_group.setVisible(False)
else:
# self.free_scrollarea.setVisible(True)
if len(self.discounts_group.layout().children()) > 0:
self.discounts_group.setVisible(True)
self.games_group.loading(True)
browse_model = SearchStoreQuery(
language=self.store_api.language_code,
country=self.store_api.country_code,
count=20,
price_range=self.price,
on_sale=self.ui.on_discount.isChecked(),
)
browse_model.tag = "|".join(self.tags)
if self.types:
browse_model.category = "|".join(self.types)
self.store_api.browse_games(browse_model, self.show_games)
class CheckBox(QCheckBox):
activated = pyqtSignal(str)
deactivated = pyqtSignal(str)
def __init__(self, text, tag):
super(CheckBox, self).__init__(text)
self.tag = tag
self.toggled.connect(self.handle_toggle)
def handle_toggle(self):
if self.isChecked():
self.activated.emit(self.tag)
else:
self.deactivated.emit(self.tag)

View file

@ -1,111 +0,0 @@
from PyQt5 import QtGui
from PyQt5.QtCore import pyqtSignal, Qt
from PyQt5.QtGui import QFont
from PyQt5.QtWidgets import (
QWidget,
QVBoxLayout,
QHBoxLayout,
QLabel,
QScrollArea,
QGroupBox,
QPushButton,
QStackedWidget,
)
from rare.utils.extra_widgets import ImageLabel, WaitingSpinner
from rare.widgets.flow_layout import FlowLayout
class SearchResults(QStackedWidget):
show_info = pyqtSignal(dict)
def __init__(self, api_core):
super(SearchResults, self).__init__()
self.search_result_widget = QWidget()
self.api_core = api_core
self.addWidget(self.search_result_widget)
self.main_layout = QVBoxLayout()
self.back_button = QPushButton(self.tr("Back"))
self.main_layout.addWidget(self.back_button)
self.main_layout.addWidget(self.back_button)
self.result_area = QScrollArea()
self.widget = QWidget()
self.result_area.setWidgetResizable(True)
self.main_layout.addWidget(self.result_area)
self.result_area.setVerticalScrollBarPolicy(Qt.ScrollBarAsNeeded)
self.result_area.setWidget(self.widget)
self.layout = FlowLayout()
self.widget.setLayout(self.layout)
self.search_result_widget.setLayout(self.main_layout)
self.addWidget(WaitingSpinner())
self.setCurrentIndex(1)
def load_results(self, text: str):
self.setCurrentIndex(1)
if text != "":
self.api_core.search_game(text, self.show_results)
def show_results(self, results: dict):
self.widget.deleteLater()
self.widget = QWidget()
self.layout = FlowLayout()
if not results:
self.layout.addWidget(QLabel(self.tr("No results found")))
else:
for res in results:
w = _SearchResultItem(res)
w.show_info.connect(self.show_info.emit)
self.layout.addWidget(w)
self.widget.setLayout(self.layout)
self.result_area.setWidget(self.widget)
self.setCurrentIndex(0)
class _SearchResultItem(QGroupBox):
res: dict
show_info = pyqtSignal(dict)
def __init__(self, result: dict):
super(_SearchResultItem, self).__init__()
self.layout = QVBoxLayout()
self.image = ImageLabel()
for img in result["keyImages"]:
if img["type"] == "DieselStoreFrontTall":
width = 240
self.image.update_image(img["url"], result["title"], (width, 360))
break
else:
print("No image found")
self.layout.addWidget(self.image)
self.res = result
self.title = QLabel(self.res["title"])
title_font = QFont()
title_font.setPixelSize(15)
self.title.setFont(title_font)
self.title.setWordWrap(True)
self.layout.addWidget(self.title)
price = result["price"]["totalPrice"]["fmtPrice"]["originalPrice"]
discount_price = result["price"]["totalPrice"]["fmtPrice"]["discountPrice"]
price_layout = QHBoxLayout()
price_label = QLabel(price if price != "0" else self.tr("Free"))
price_layout.addWidget(price_label)
if price != discount_price:
font = QFont()
font.setStrikeOut(True)
price_label.setFont(font)
price_layout.addWidget(QLabel(discount_price))
# self.discount_price = QLabel(f"{self.tr('Discount price: ')}{discount_price}")
self.layout.addLayout(price_layout)
self.setLayout(self.layout)
self.setFixedWidth(260)
def mousePressEvent(self, a0: QtGui.QMouseEvent) -> None:
if a0.button() == 1:
self.show_info.emit(self.res)

Some files were not shown because too many files have changed in this diff Show more