From 1a8cd581d732f39edf4c817900d75cd960632ebc Mon Sep 17 00:00:00 2001 From: Alessio Attilio <317110166+alessio-attilio@users.noreply.github.com> Date: Thu, 20 Aug 2026 22:19:58 +0200 Subject: [PATCH 1/2] Fix store API GraphQL host and auth for searchStore/wishlist --- rare/components/tabs/store/__init__.py | 3 +- rare/components/tabs/store/store_api.py | 185 ++++++++++++++++++------ rare/utils/qrequests.py | 53 +++++-- 3 files changed, 176 insertions(+), 65 deletions(-) diff --git a/rare/components/tabs/store/__init__.py b/rare/components/tabs/store/__init__.py index c6693b68b..3cffa623e 100644 --- a/rare/components/tabs/store/__init__.py +++ b/rare/components/tabs/store/__init__.py @@ -20,7 +20,8 @@ def __init__(self, core: LegendaryCore, parent=None): 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.core.egs._store_user_agent, ) self.landing = LandingPage(self.api, parent=self) diff --git a/rare/components/tabs/store/store_api.py b/rare/components/tabs/store/store_api.py index a14d18215..870ea22c0 100644 --- a/rare/components/tabs/store/store_api.py +++ b/rare/components/tabs/store/store_api.py @@ -5,12 +5,12 @@ from PySide6.QtWidgets import QApplication from rare.components.tabs.store.constants import ( + purchase_query, search_query, wishlist_add_query, wishlist_query, wishlist_remove_query, ) -from rare.utils.paths import cache_dir from rare.utils.qrequests import QRequests from .api.models.diesel import DieselProduct @@ -19,8 +19,7 @@ ResponseModel, ) -graphql_url = 'https://store.epicgames.com/graphql' -# graphql_url = "https://launcher.store.epicgames.com/graphql" +graphql_url = 'https://launcher.store.epicgames.com/graphql' def DEBUG() -> bool: @@ -30,7 +29,7 @@ def DEBUG() -> bool: class StoreAPI(QObject): update_wishlist = Signal() - def __init__(self, token, language: str, country: str, installed): + def __init__(self, token, language: str, country: str, installed, user_agent: str): super(StoreAPI, self).__init__() self.logger = getLogger(type(self).__name__) self.token = token @@ -38,8 +37,8 @@ def __init__(self, token, language: str, country: str, installed): self.country_code: str = country self.locale = f'{self.language_code}-{self.country_code}' self.manager = QRequests(parent=self) - self.authed_manager = QRequests(token=token, parent=self) - self.cached_manager = QRequests(cache=str(cache_dir().joinpath('store')), parent=self) + self.authed_manager = QRequests(token=token, user_agent=user_agent, parent=self) + self.image_manager = QRequests(parent=self) self.installed = installed @@ -56,16 +55,26 @@ def get_free(self, callback: Callable): self.manager.get(url, lambda data: self.__handle_free_games(data, callback), params=params) def __handle_free_games(self, data, callback: Callable): + elements = False try: response = ResponseModel.from_dict(data) if response.errors: for error in response.errors: - self.logger.error(error) + self.logger.error('Free games request failed: %s', error) + callback(elements) + return + if ( + response.data is None + or response.data.catalog is None + or response.data.catalog.searchStore is None + ): + self.logger.error('Free games request returned no data') + callback(elements) + return elements = response.data.catalog.searchStore.elements except (Exception, AttributeError, KeyError) as e: if DEBUG(): raise - elements = False self.logger.error('Free games request failed with: %s', e) callback(elements) @@ -84,16 +93,26 @@ def get_wishlist(self, callback: Callable): ) def __handle_wishlist(self, data, callback: Callable[[tuple], None]): + elements = False try: response = ResponseModel.from_dict(data) if response.errors: for error in response.errors: - self.logger.error(error) + self.logger.error('Wishlist request failed: %s', error) + callback(elements) + return + if ( + response.data is None + or response.data.wishlist is None + or response.data.wishlist.wishlistItems is None + ): + self.logger.error('Wishlist request returned no data') + callback(elements) + return elements = response.data.wishlist.wishlistItems.elements except (Exception, AttributeError, KeyError) as e: if DEBUG(): raise - elements = False self.logger.error('Wishlist request failed with: %s', e) callback(elements) @@ -115,19 +134,29 @@ def search_game(self, name, callback: Callable): }, } - self.manager.post(graphql_url, lambda data: self.__handle_search(data, callback), payload) + self.authed_manager.post(graphql_url, lambda data: self.__handle_search(data, callback), payload) def __handle_search(self, data, callback: Callable[[tuple], None]): + elements = False try: response = ResponseModel.from_dict(data) if response.errors: for error in response.errors: - self.logger.error(error) + self.logger.error('Search request failed: %s', error) + callback(elements) + return + if ( + response.data is None + or response.data.catalog is None + or response.data.catalog.searchStore is None + ): + self.logger.error('Search request returned no data') + callback(elements) + return elements = response.data.catalog.searchStore.elements except (Exception, AttributeError, KeyError) as e: if DEBUG(): raise - elements = False self.logger.error('Search request failed with: %s', e) callback(elements) @@ -137,7 +166,7 @@ def browse_games(self, browse_model: SearchStoreQuery, callback): return self.browse_active = True payload = {'query': search_query, 'variables': browse_model.to_dict()} - self.manager.post( + self.authed_manager.post( graphql_url, lambda data: self.__handle_browse_games(data, callback), payload, @@ -145,38 +174,36 @@ def browse_games(self, browse_model: SearchStoreQuery, callback): def __handle_browse_games(self, data, callback): self.browse_active = False - if data is None: - data = {} - if not self.next_browse_request: - try: - response = ResponseModel.from_dict(data) - if response.errors: - for error in response.errors: - self.logger.error(error) - elements = response.data.catalog.searchStore.elements - except (Exception, AttributeError, KeyError) as e: - if DEBUG(): - raise - elements = False - self.logger.error('Browse request failed with: %s', e) - callback(elements) - else: + if self.next_browse_request: self.browse_games(*self.next_browse_request) # pylint: disable=E1120 self.next_browse_request = () - - # def get_game_config_graphql(self, namespace: str, callback): - # payload = { - # "query": config_query, - # "variables": { - # "namespace": namespace - # } - # } - - def __make_graphql_query(self): - pass - - def __make_api_query(self): - pass + return + if data is None: + self.logger.error('Browse request failed') + callback(False) + return + try: + response = ResponseModel.from_dict(data) + if response.errors: + for error in response.errors: + self.logger.error('Browse request failed: %s', error) + callback(False) + return + if ( + response.data is None + or response.data.catalog is None + or response.data.catalog.searchStore is None + ): + self.logger.error('Browse request returned no data') + callback(False) + return + elements = response.data.catalog.searchStore.elements + except (Exception, AttributeError, KeyError) as e: + if DEBUG(): + raise + elements = False + self.logger.error('Browse request failed with: %s', e) + callback(elements) def get_game_config_cms(self, slug: str, is_bundle: bool, callback: Callable): url = 'https://store-content.ak.epicgames.com/api' @@ -212,17 +239,27 @@ def add_to_wishlist(self, namespace, offer_id, callback: Callable): ) def _handle_add_to_wishlist(self, data, callback): + success = False try: response = ResponseModel.from_dict(data) if response.errors: for error in response.errors: - self.logger.error(error) + self.logger.error('Add to wishlist request failed: %s', error) + callback(success) + return + if ( + response.data is None + or response.data.wishlist is None + or response.data.wishlist.addToWishlist is None + ): + self.logger.error('Add to wishlist request returned no data') + callback(success) + return success = response.data.wishlist.addToWishlist.success except Exception as e: if DEBUG(): raise self.logger.error('Add to wishlist request failed with: %s', e) - success = False callback(success) self.update_wishlist.emit() @@ -242,16 +279,68 @@ def remove_from_wishlist(self, namespace, offer_id, callback: Callable): ) def _handle_remove_from_wishlist(self, data, callback): + success = False try: response = ResponseModel.from_dict(data) if response.errors: for error in response.errors: - self.logger.error(error) + self.logger.error('Remove from wishlist request failed: %s', error) + callback(success) + return + if ( + response.data is None + or response.data.wishlist is None + or response.data.wishlist.removeFromWishlist is None + ): + self.logger.error('Remove from wishlist request returned no data') + callback(success) + return success = response.data.wishlist.removeFromWishlist.success except Exception as e: if DEBUG(): raise self.logger.error('Remove from wishlist request failed with: %s', e) - success = False callback(success) self.update_wishlist.emit() + + def purchase_free_game(self, namespace: str, offer_id: str, callback: Callable[[bool], None]): + payload = { + 'query': purchase_query, + 'variables': { + 'namespace': namespace, + 'offerId': offer_id, + 'country': self.country_code, + 'locale': self.locale, + }, + } + self.authed_manager.post( + graphql_url, + lambda data: self._handle_purchase(data, callback), + payload, + ) + + def _handle_purchase(self, data, callback: Callable[[bool], None]): + success = False + try: + response = ResponseModel.from_dict(data) + if response.errors: + for error in response.errors: + self.logger.error('Purchase request failed: %s', error) + callback(success) + return + if ( + response.data is None + or response.data.catalog is None + or response.data.catalog.purchase is None + ): + self.logger.error('Purchase request returned no data') + callback(success) + return + code = response.data.catalog.purchase.code + success = code in {'OK', 'ALREADY_IN_LIBRARY', 'OK_NO_CHANGE'} + except Exception as e: + if DEBUG(): + raise + self.logger.error('Purchase request failed with: %s', e) + success = False + callback(success) diff --git a/rare/utils/qrequests.py b/rare/utils/qrequests.py index 4028093b6..dd71a3629 100644 --- a/rare/utils/qrequests.py +++ b/rare/utils/qrequests.py @@ -14,7 +14,6 @@ ) user_agent = 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/92.0.4515.159 Safari/537.36' -# user_agent = f'UELauncher/{version} Windows/10.0.19041.1.256.64bit' RequestHandler = TypeVar('RequestHandler', bound=Callable[[dict | bytes], None]) @@ -33,7 +32,7 @@ def __eq__(self, other): class QRequests(QObject): data_ready = Signal(object) - def __init__(self, cache: str | None = None, token: str | None = None, parent=None): + def __init__(self, cache: str | None = None, token: str | None = None, user_agent: str = user_agent, parent=None): super(QRequests, self).__init__(parent=parent) self.logger = getLogger(f'{type(self).__name__}_{type(parent).__name__}') self._manager = QNetworkAccessManager(self) @@ -47,6 +46,7 @@ def __init__(self, cache: str | None = None, token: str | None = None, parent=No if token is not None: self.logger.debug('Manager is authorized') self._token = token + self._user_agent = user_agent self._active_requests: dict[QNetworkReply, RequestQueueItem] = {} @@ -65,11 +65,13 @@ def _prepare_request(self, item: RequestQueueItem) -> QNetworkRequest: QNetworkRequest.KnownHeaders.ContentTypeHeader, 'application/json; charset=UTF-8', ) - request.setHeader(QNetworkRequest.KnownHeaders.UserAgentHeader, user_agent) + request.setRawHeader(b'Accept', b'application/json') + request.setHeader(QNetworkRequest.KnownHeaders.UserAgentHeader, self._user_agent) request.setAttribute( QNetworkRequest.Attribute.RedirectPolicyAttribute, QNetworkRequest.RedirectPolicy.NoLessSafeRedirectPolicy, ) + request.setAttribute(QNetworkRequest.Attribute.Http2AllowedAttribute, False) if self._cache is not None: request.setAttribute( QNetworkRequest.Attribute.CacheLoadControlAttribute, @@ -79,10 +81,18 @@ def _prepare_request(self, item: RequestQueueItem) -> QNetworkRequest: request.setRawHeader(b'Authorization', self._token.encode()) return request + def _fail(self, item: RequestQueueItem) -> None: + for handler in item.handlers: + handler(None) + def _post(self, item: RequestQueueItem): request = self._prepare_request(item) payload = orjson.dumps(item.payload) reply = self._manager.post(request, payload) + if not isinstance(reply, QNetworkReply): + self.logger.error('Network request did not return a QNetworkReply: %r', reply) + self._fail(item) + return reply.errorOccurred.connect(self._on_error) self._active_requests[reply] = item @@ -93,6 +103,10 @@ def post(self, url: str, handler: RequestHandler, payload: dict): def _get(self, item: RequestQueueItem): request = self._prepare_request(item) reply = self._manager.get(request) + if not isinstance(reply, QNetworkReply): + self.logger.error('Network request did not return a QNetworkReply: %r', reply) + self._fail(item) + return reply.errorOccurred.connect(self._on_error) self._active_requests[reply] = item @@ -121,22 +135,29 @@ def _parse_content_type(header) -> tuple[str, str | None]: @Slot(QNetworkReply) def _on_finished(self, reply: QNetworkReply): + if not isinstance(reply, QNetworkReply): + self.logger.error('Finished signal delivered a non-reply object: %r', reply) + return item = self._active_requests.pop(reply, None) if item is None: self.logger.error('QNetworkReply: %s without associated item', reply.url().toString()) - elif reply.error() != QNetworkReply.NetworkError.NoError: - self.logger.error(reply.errorString()) + return + if reply.error() != QNetworkReply.NetworkError.NoError: + self.logger.error('Network request failed: %s', reply.errorString()) + self._fail(item) + reply.deleteLater() + return + + mimetype, _charset = self._parse_content_type(reply.header(QNetworkRequest.KnownHeaders.ContentTypeHeader)) + maintype, _subtype = mimetype.split('/') + bin_data = reply.readAll().data() + if mimetype == 'application/json': + data = orjson.loads(bin_data) + elif maintype == 'image': + data = bin_data else: - mimetype, _charset = self._parse_content_type(reply.header(QNetworkRequest.KnownHeaders.ContentTypeHeader)) - maintype, _subtype = mimetype.split('/') - bin_data = reply.readAll().data() - if mimetype == 'application/json': - data = orjson.loads(bin_data) - elif maintype == 'image': - data = bin_data - else: - data = None - for handler in item.handlers: - handler(data) + data = None + for handler in item.handlers: + handler(data) reply.disconnect(reply) reply.deleteLater() From b793b8fba58823d79f9fd3117b9d06c544036235 Mon Sep 17 00:00:00 2001 From: Alessio Attilio <317110166+alessio-attilio@users.noreply.github.com> Date: Thu, 20 Aug 2026 22:23:37 +0200 Subject: [PATCH 2/2] Implement store purchase and download feature --- rare/components/tabs/__init__.py | 3 +- rare/components/tabs/store/__init__.py | 20 +- rare/components/tabs/store/__main__.py | 2 +- .../components/tabs/store/api/models/query.py | 27 +- rare/components/tabs/store/constants.py | 28 ++ rare/components/tabs/store/landing.py | 278 +++++++++++++---- rare/components/tabs/store/search.py | 279 +++--------------- rare/components/tabs/store/widgets/details.py | 123 +++++++- rare/components/tabs/store/widgets/filters.py | 127 ++++++++ rare/components/tabs/store/widgets/groups.py | 60 +++- rare/components/tabs/store/widgets/items.py | 104 ++++--- rare/components/tabs/store/wishlist.py | 52 ++-- rare/widgets/image_widget.py | 2 +- rare/widgets/loading_widget.py | 4 +- 14 files changed, 700 insertions(+), 409 deletions(-) create mode 100644 rare/components/tabs/store/widgets/filters.py diff --git a/rare/components/tabs/__init__.py b/rare/components/tabs/__init__.py index c4664e826..3fa0dc7c5 100644 --- a/rare/components/tabs/__init__.py +++ b/rare/components/tabs/__init__.py @@ -45,7 +45,8 @@ def __init__(self, settings: RareAppSettings, rcore: RareCore, parent): self.setTabEnabled(self.downloads_index, not self.args.offline) if not self.args.offline: - self.store_tab = StoreTab(self.core, parent=self) + self.store_tab = StoreTab(self.core, self.rcore, parent=self) + self.store_tab.open_library.connect(self.games_tab.show_library) self.store_index = self.addTab(self.store_tab, self.tr('Store (Under Construction)')) self.setTabEnabled(self.store_index, not self.args.offline) diff --git a/rare/components/tabs/store/__init__.py b/rare/components/tabs/store/__init__.py index 3cffa623e..1dbd36688 100644 --- a/rare/components/tabs/store/__init__.py +++ b/rare/components/tabs/store/__init__.py @@ -1,21 +1,24 @@ +from PySide6.QtCore import Signal from PySide6.QtGui import QHideEvent, QShowEvent from rare.lgndr.core import LegendaryCore +from rare.shared import RareCore from rare.widgets.side_tab import SideTabWidget from .landing import LandingPage -from .search import SearchPage from .store_api import StoreAPI from .wishlist import WishlistPage class StoreTab(SideTabWidget): - def __init__(self, core: LegendaryCore, parent=None): + open_library = Signal() + + def __init__(self, core: LegendaryCore, rcore: RareCore | None = None, parent=None): super(StoreTab, self).__init__(parent=parent) self.init = False self.core = core - # self.rcore = rcore + self.rcore = rcore self.api = StoreAPI( self.core.egs.session.headers['Authorization'], self.core.language_code, @@ -24,15 +27,16 @@ def __init__(self, core: LegendaryCore, parent=None): self.core.egs._store_user_agent, ) - self.landing = LandingPage(self.api, parent=self) + self.landing = LandingPage(self.api, self.rcore, parent=self) self.landing_index = self.addTab(self.landing, self.tr('Store')) - self.search = SearchPage(self.api, parent=self) - self.search_index = self.addTab(self.search, self.tr('Search')) - - self.wishlist = WishlistPage(self.api, parent=self) + self.wishlist = WishlistPage(self.api, self.rcore, parent=self) self.wishlist_index = self.addTab(self.wishlist, self.tr('Wishlist')) + # forward library navigation requests from the details pages + self.landing.open_library.connect(self.open_library) + self.wishlist.open_library.connect(self.open_library) + def showEvent(self, a0: QShowEvent) -> None: if a0.spontaneous() or self.init: return super().showEvent(a0) diff --git a/rare/components/tabs/store/__main__.py b/rare/components/tabs/store/__main__.py index 949c7ab33..ca5d291cb 100644 --- a/rare/components/tabs/store/__main__.py +++ b/rare/components/tabs/store/__main__.py @@ -14,7 +14,7 @@ def __init__(self): self.core = LegendaryCore() self.core.login() - self.store_tab = StoreTab(self.core, self) + self.store_tab = StoreTab(self.core, parent=self) layout = QVBoxLayout(self) layout.addWidget(self.store_tab) diff --git a/rare/components/tabs/store/api/models/query.py b/rare/components/tabs/store/api/models/query.py index 0e4392457..4f80be8e4 100644 --- a/rare/components/tabs/store/api/models/query.py +++ b/rare/components/tabs/store/api/models/query.py @@ -41,7 +41,13 @@ def __post_init__(self): self.locale = f'{self.language}-{self.country}' def to_dict(self): - payload = { + price_range = '' + free_game = self.free_game + if self.price_range == 'free': + free_game = True + elif self.price_range.startswith(''): + price_range = self.price_range.replace('', '') + return { 'allowCountries': self.country, 'category': self.category, 'count': self.count, @@ -58,23 +64,8 @@ def to_dict(self): 'releaseDate': str(self.release_date), 'withPrice': self.with_price, 'withPromotions': self.with_promotions, - 'priceRange': self.price_range, - 'freeGame': self.free_game, + 'priceRange': price_range, + 'freeGame': 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(''): - payload['priceRange'] = self.price_range.replace('', '') - if self.on_sale: - payload['onSale'] = True - - if self.price_range: - payload['effectiveDate'] = self.effective_date - else: - payload.pop('priceRange') - return payload diff --git a/rare/components/tabs/store/constants.py b/rare/components/tabs/store/constants.py index 8adb6da61..91e609362 100644 --- a/rare/components/tabs/store/constants.py +++ b/rare/components/tabs/store/constants.py @@ -324,6 +324,33 @@ def __init__(self): } """ +PURCHASE_QUERY = """ +mutation purchaseMutation( + $namespace: String! + $offerId: String! + $country: String! + $locale: String! +) { + Catalog { + purchase( + namespace: $namespace + offerId: $offerId + country: $country + locale: $locale + ) { + code + error { + code + message + } + cart { + id + } + } + } +} +""" + COUPONS_QUERY = """ query getCoupons( $currencyCountry: String! @@ -441,6 +468,7 @@ def compress_query(query: str) -> str: wishlist_remove_query = compress_query(WISHLIST_REMOVE_QUERY) coupons_query = compress_query(COUPONS_QUERY) store_config_query = compress_query(STORE_CONFIG_QUERY) +purchase_query = compress_query(PURCHASE_QUERY) if __name__ == '__main__': diff --git a/rare/components/tabs/store/landing.py b/rare/components/tabs/store/landing.py index d40dc64dc..9ed508074 100644 --- a/rare/components/tabs/store/landing.py +++ b/rare/components/tabs/store/landing.py @@ -1,10 +1,12 @@ from datetime import datetime, timezone from logging import getLogger -from PySide6.QtCore import Qt, Signal, Slot +from PySide6.QtCore import QSize, Qt, Signal, Slot from PySide6.QtGui import QHideEvent, QShowEvent from PySide6.QtWidgets import ( QFrame, + QHBoxLayout, + QPushButton, QScrollArea, QSizePolicy, QSpacerItem, @@ -12,16 +14,21 @@ QWidget, ) +from rare.components.tabs.store.api.models.query import SearchStoreQuery from rare.components.tabs.store.api.models.response import ( CatalogOfferModel, WishlistItemModel, ) -from rare.widgets.flow_layout import FlowLayout +from rare.shared import RareCore +from rare.widgets.button_edit import ButtonLineEdit +from rare.widgets.loading_widget import LoadingWidget from rare.widgets.side_tab import SideTabContents from rare.widgets.sliding_stack import SlidingStackedWidget +from .search import ResultsWidget from .store_api import StoreAPI from .widgets.details import StoreDetailsWidget +from .widgets.filters import FiltersWidget from .widgets.groups import StoreGroup from .widgets.items import StoreItemWidget @@ -29,80 +36,159 @@ class LandingPage(SlidingStackedWidget, SideTabContents): - def __init__(self, store_api: StoreAPI, parent=None): + open_library = Signal() + + def __init__(self, store_api: StoreAPI, rcore: RareCore | None = None, parent=None): super(LandingPage, self).__init__(parent=parent) self.implements_scrollarea = True + self.api = store_api + self.rcore = rcore - self.landing_widget = LandingWidget(store_api, parent=self) + self.landing_widget = LandingWidget(store_api, rcore, parent=self) self.landing_widget.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding) self.landing_widget.set_title.connect(self.set_title) self.landing_widget.show_details.connect(self.show_details) + self.landing_widget.search_requested.connect(self.on_search) + self._scrollbar = self.landing_widget.main_scroll.verticalScrollBar() + self._scrollbar.valueChanged.connect(self._on_scroll) - self.landing_scroll = QScrollArea(self) - self.landing_scroll.setWidgetResizable(True) - self.landing_scroll.setFrameStyle(QFrame.Shape.NoFrame | QFrame.Shadow.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 = StoreDetailsWidget(rcore, store_api, parent=self) self.details_widget.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding) self.details_widget.set_title.connect(self.set_title) self.details_widget.back_clicked.connect(self.show_main) + self.details_widget.open_library.connect(self.open_library) self.setDirection(Qt.Orientation.Horizontal) - self.addWidget(self.landing_scroll) + self.addWidget(self.landing_widget) self.addWidget(self.details_widget) + self.search_results = ResultsWidget(self.api, self.rcore, self) + self.search_results.show_details.connect(self.show_details) + self.search_back_btn = QPushButton(self.tr('Back to store')) + self.search_back_btn.clicked.connect(self.show_main) + self.search_container = QWidget(self) + search_layout = QVBoxLayout(self.search_container) + search_layout.setContentsMargins(0, 0, 3, 0) + search_layout.addWidget(self.search_back_btn) + search_layout.addWidget(self.search_results) + self.addWidget(self.search_container) + @Slot() def show_main(self): - self.slideInWidget(self.landing_scroll) + self.slideInWidget(self.landing_widget) @Slot(object) def show_details(self, game: CatalogOfferModel): self.details_widget.update_game(game) self.slideInWidget(self.details_widget) + @Slot(str) + def on_search(self, text: str): + if text: + self.api.search_game(text, self._on_search_results) + + def _on_search_results(self, results): + self.search_results.show_results(results) + self.slideInWidget(self.search_container) + + @Slot(int) + def _on_scroll(self, value: int): + if self._scrollbar.maximum() > 0 and value >= self._scrollbar.maximum() - 40: + self.landing_widget.load_more_browse() + class LandingWidget(QWidget, SideTabContents): show_details = Signal(CatalogOfferModel) + search_requested = Signal(str) - def __init__(self, api: StoreAPI, parent=None): + def __init__(self, api: StoreAPI, rcore: RareCore | None = None, parent=None): super(LandingWidget, self).__init__(parent=parent) self.api = api + self.rcore = rcore - layout = QVBoxLayout(self) + layout = QHBoxLayout(self) layout.setContentsMargins(0, 0, 3, 0) + layout.setSpacing(12) self.setLayout(layout) - self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed) + self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding) + + self.main_column = QWidget(self) + self.main_column.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed) + main_layout = QVBoxLayout(self.main_column) + main_layout.setContentsMargins(0, 0, 0, 0) + main_layout.setSpacing(12) + + self.search_bar = ButtonLineEdit('fa5s.search', placeholder_text=self.tr('Search')) + self.search_bar.returnPressed.connect(lambda: self.search_requested.emit(self.search_bar.text())) + self.search_bar.buttonClicked.connect(lambda: self.search_requested.emit(self.search_bar.text())) + main_layout.addWidget(self.search_bar, alignment=Qt.AlignmentFlag.AlignTop) - self.free_games_now = StoreGroup(self.tr('Free now'), layout=FlowLayout, parent=self) - self.free_games_now.main_layout.setAlignment(Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter) + self.free_games_now = StoreGroup(self.tr('Free now'), parent=self.main_column) self.free_games_now.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding) + self.free_games_now.loading(True) - self.free_games_next = StoreGroup(self.tr('Free next week'), layout=FlowLayout, parent=self) - self.free_games_next.main_layout.setAlignment(Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter) + self.free_games_next = StoreGroup(self.tr('Free next week'), parent=self.main_column) self.free_games_next.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding) + self.free_games_next.loading(True) - self.discounts_group = StoreGroup(self.tr('Wishlist discounts'), layout=FlowLayout, parent=self) + self.discounts_group = StoreGroup(self.tr('Wishlist discounts'), parent=self.main_column) self.discounts_group.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding) - - self.games_group = StoreGroup(self.tr('Free to play'), FlowLayout, self) - self.games_group.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding) - self.games_group.loading(False) - self.games_group.setVisible(False) - - layout.addWidget(self.free_games_now, alignment=Qt.AlignmentFlag.AlignTop) - layout.addWidget(self.free_games_next, alignment=Qt.AlignmentFlag.AlignTop) - layout.addWidget(self.discounts_group, alignment=Qt.AlignmentFlag.AlignTop) - layout.addWidget(self.games_group, alignment=Qt.AlignmentFlag.AlignTop) - layout.addItem(QSpacerItem(0, 0, QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Expanding)) + self.discounts_group.loading(True) + + self._browse_sections = {} + self.free_to_play_group = self._create_browse_section('free', self.tr('Free to play')) + self._browse_sections['free']['query'].price_range = 'free' + self.on_sale_group = self._create_browse_section('sale', self.tr('On sale')) + self._browse_sections['sale']['query'].on_sale = True + self.discover_group = self._create_browse_section('discover', self.tr('Discover')) + self.discover_group.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding) + + main_layout.addWidget(self.free_games_now, alignment=Qt.AlignmentFlag.AlignTop) + main_layout.addWidget(self.free_games_next, alignment=Qt.AlignmentFlag.AlignTop) + main_layout.addWidget(self.free_to_play_group, alignment=Qt.AlignmentFlag.AlignTop) + main_layout.addWidget(self.discounts_group, alignment=Qt.AlignmentFlag.AlignTop) + main_layout.addWidget(self.on_sale_group, alignment=Qt.AlignmentFlag.AlignTop) + main_layout.addWidget(self.discover_group, stretch=1) + + self.main_scroll = QScrollArea(self) + self.main_scroll.setWidgetResizable(True) + self.main_scroll.setFrameShape(QFrame.Shape.NoFrame) + self.main_scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff) + self.main_scroll.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding) + self.main_scroll.setWidget(self.main_column) + layout.addWidget(self.main_scroll, stretch=1) + + self.filters = FiltersWidget(self) + self.filters.changed.connect(self._apply_filters) + + self.filters_column = QWidget(self) + self.filters_column.setSizePolicy(QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Expanding) + self.filters_column.setMinimumWidth(240) + self.filters_column.setMaximumWidth(280) + filters_layout = QVBoxLayout(self.filters_column) + filters_layout.setContentsMargins(0, 0, 0, 0) + filters_layout.setSpacing(6) + + self.reset_filters_btn = QPushButton(self.tr('Reset filters')) + self.reset_filters_btn.clicked.connect(self.filters.reset) + filters_layout.addWidget(self.reset_filters_btn) + + self.filters_scroll = QScrollArea(self) + self.filters_scroll.setWidgetResizable(True) + self.filters_scroll.setFrameShape(QFrame.Shape.NoFrame) + self.filters_scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff) + self.filters_scroll.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding) + self.filters_scroll.setWidget(self.filters) + filters_layout.addWidget(self.filters_scroll) + + layout.addWidget(self.filters_column, stretch=0) 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) + self._apply_filters() return super().showEvent(a0) def hideEvent(self, a0: QHideEvent) -> None: @@ -112,29 +198,29 @@ def hideEvent(self, a0: QHideEvent) -> None: return super().hideEvent(a0) def _update_wishlist_discounts(self, wishlist: list[WishlistItemModel]): - for w in self.discounts_group.findChildren(StoreItemWidget, options=Qt.FindChildOption.FindDirectChildrenOnly): - self.discounts_group.layout().removeWidget(w) - w.disconnect(w) - w.deleteLater() + self.discounts_group.clear_widgets() + if not wishlist: + self.discounts_group.setVisible(False) + self.discounts_group.loading(False) + return for item in filter(lambda x: bool(x.offer.price.totalPrice.discount), wishlist): - w = StoreItemWidget(self.api.cached_manager, item.offer) + w = StoreItemWidget(self.api.image_manager, item.offer, self.rcore) w.show_details.connect(self.show_details) - self.discounts_group.layout().addWidget(w) + self.discounts_group.add_widget(w) have_discounts = any(bool(x.offer.price.totalPrice.discount) for x in 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.FindChildOption.FindDirectChildrenOnly): - self.free_games_now.layout().removeWidget(w) - w.disconnect(w) - w.deleteLater() - - for w in self.free_games_next.findChildren(StoreItemWidget, options=Qt.FindChildOption.FindDirectChildrenOnly): - self.free_games_next.layout().removeWidget(w) - w.disconnect(w) - w.deleteLater() + self.free_games_now.clear_widgets() + self.free_games_next.clear_widgets() + if not free_games: + self.free_games_now.setVisible(False) + self.free_games_next.setVisible(False) + self.free_games_now.loading(False) + self.free_games_next.loading(False) + return date = datetime.now(timezone.utc) free_now = [] @@ -164,31 +250,97 @@ def _update_free_games(self, free_games: list[CatalogOfferModel]): # free games now self.free_games_now.setVisible(bool(free_now)) for item in free_now: - w = StoreItemWidget(self.api.cached_manager, item) + w = StoreItemWidget(self.api.image_manager, item, self.rcore) w.show_details.connect(self.show_details) - self.free_games_now.layout().addWidget(w) + self.free_games_now.add_widget(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) + w = StoreItemWidget(self.api.image_manager, item, self.rcore) if item.title != 'Mystery Game': w.show_details.connect(self.show_details) - self.free_games_next.layout().addWidget(w) + self.free_games_next.add_widget(w) self.free_games_next.loading(False) - def show_games(self, data): - if not data: + def _create_browse_section(self, name, title): + group = StoreGroup(title, parent=self.main_column) + group.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding) + group.loading(True) + spinner = LoadingWidget(parent=group) + spinner.setFixedSize(QSize(48, 48)) + spinner.setVisible(False) + self._browse_sections[name] = { + 'group': group, + 'query': SearchStoreQuery(), + 'start': 0, + 'loading': False, + 'has_more': True, + 'spinner': spinner, + 'hide_if_empty': name != 'discover', + } + return group + + def _reset_browse_section(self, name): + state = self._browse_sections[name] + state['start'] = 0 + state['has_more'] = True + state['loading'] = False + state['group'].clear_widgets() + state['spinner'].setVisible(False) + state['group'].loading(True) + self._fetch_browse_section(name) + + def _fetch_browse_section(self, name): + state = self._browse_sections[name] + if state['loading'] or not state['has_more']: + return + state['loading'] = True + if state['start'] > 0: + state['spinner'].setVisible(True) + state['query'].start = state['start'] + self.api.browse_games(state['query'], lambda elements: self._on_browse_section(name, elements)) + + def _on_browse_section(self, name, elements): + state = self._browse_sections[name] + state['loading'] = False + state['spinner'].setVisible(False) + state['group'].loading(False) + + if state['start'] == 0 and not elements: + if state['hide_if_empty']: + state['group'].setVisible(False) + else: + state['group'].set_empty(self.tr('No games found')) return - for w in self.games_group.findChildren(StoreItemWidget, options=Qt.FindChildOption.FindDirectChildrenOnly): - self.games_group.layout().removeWidget(w) - w.disconnect(w) - w.deleteLater() - - for game in data: - w = StoreItemWidget(self.api.cached_manager, game) + state['group'].setVisible(True) + for game in elements: + w = StoreItemWidget(self.api.image_manager, game, self.rcore) w.show_details.connect(self.show_details) - self.games_group.layout().addWidget(w) - self.games_group.loading(False) + state['group'].add_widget(w) + + state['start'] += len(elements) + if len(elements) < state['query'].count: + state['has_more'] = False + + def load_more_browse(self): + self._fetch_browse_section('discover') + + def _apply_filters(self): + query = SearchStoreQuery() + self.filters.configure(query) + self._browse_sections['discover']['query'] = query + + active = self.filters.is_active() + self.free_games_now.setVisible(not active) + self.free_games_next.setVisible(not active) + self.discounts_group.setVisible(not active) + self._browse_sections['free']['group'].setVisible(not active) + self._browse_sections['sale']['group'].setVisible(not active) + + self._reset_browse_section('discover') + if not active: + self._reset_browse_section('free') + self._reset_browse_section('sale') diff --git a/rare/components/tabs/store/search.py b/rare/components/tabs/store/search.py index e74adea05..c49e5ab26 100644 --- a/rare/components/tabs/store/search.py +++ b/rare/components/tabs/store/search.py @@ -1,275 +1,72 @@ -from logging import getLogger - -from PySide6.QtCore import Qt, Signal, Slot +from PySide6.QtCore import Qt, Signal from PySide6.QtWidgets import ( - QCheckBox, QLabel, + QGridLayout, QScrollArea, QSizePolicy, QWidget, ) -from rare.ui.components.tabs.store.search import Ui_SearchWidget -from rare.widgets.button_edit import ButtonLineEdit -from rare.widgets.flow_layout import FlowLayout -from rare.widgets.side_tab import SideTabContents -from rare.widgets.sliding_stack import SlidingStackedWidget - -from .api.models.query import SearchStoreQuery +from rare.shared import RareCore from .api.models.response import CatalogOfferModel -from .constants import Constants -from .store_api import StoreAPI -from .widgets.details import StoreDetailsWidget -from .widgets.items import SearchItemWidget - -logger = 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.Policy.Expanding, QSizePolicy.Policy.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.Policy.Expanding, QSizePolicy.Policy.Fixed) - self.details_widget.set_title.connect(self.set_title) - self.details_widget.back_clicked.connect(self.show_main) - - self.setDirection(Qt.Orientation.Horizontal) - self.addWidget(self.search_widget) - self.addWidget(self.details_widget) - - @Slot() - def show_main(self): - self.slideInWidget(self.search_widget) - - @Slot(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 = Signal(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('fa5s.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.none_price.toggled.connect( - (lambda obj: obj.prepare_request('') if self.ui.none_price.isChecked() else None).__get__(self) - ) - # self.ui.free_button.toggled.connect(lambda: self.prepare_request("free") if self.ui.free_button.isChecked() else None) - self.ui.free_button.toggled.connect( - (lambda obj: obj.prepare_request('free') if self.ui.free_button.isChecked() else None).__get__(self) - ) - # self.ui.under10.toggled.connect(lambda: self.prepare_request("[0, 1000)") if self.ui.under10.isChecked() else None) - self.ui.under10.toggled.connect( - (lambda obj: obj.prepare_request('[0, 1000)') if self.ui.under10.isChecked() else None).__get__(self) - ) - # self.ui.under20.toggled.connect(lambda: self.prepare_request("[0, 2000)") if self.ui.under20.isChecked() else None) - self.ui.under20.toggled.connect( - (lambda obj: obj.prepare_request('[0, 2000)') if self.ui.under20.isChecked() else None).__get__(self) - ) - # self.ui.under30.toggled.connect(lambda: self.prepare_request("[0, 3000)") if self.ui.under30.isChecked() else None) - self.ui.under30.toggled.connect( - (lambda obj: obj.prepare_request('[0, 3000)') if self.ui.under30.isChecked() else None).__get__(self) - ) - # self.ui.above.toggled.connect(lambda: self.prepare_request("[1499,]") if self.ui.above.isChecked() else None) - self.ui.above.toggled.connect( - (lambda obj: obj.prepare_request('[1499,]') if self.ui.above.isChecked() else None).__get__(self) - ) - # 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()) - self.ui.on_discount.toggled.connect((lambda obj: obj.prepare_request()).__get__(self)) - 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.activated.connect((lambda obj, x: obj.prepare_request(added_tag=x)).__get__(self)) - # checkbox.deactivated.connect(lambda x: self.prepare_request(removed_tag=x)) - checkbox.deactivated.connect((lambda obj, x: obj.prepare_request(removed_tag=x)).__get__(self)) - 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 = 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 = Signal(str) - deactivated = Signal(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) +from .widgets.items import SearchItemWidget, StoreItemWidget class ResultsWidget(QScrollArea): + COLUMNS = 3 show_details = Signal(CatalogOfferModel) - def __init__(self, store_api, parent=None): + def __init__(self, store_api, rcore: RareCore | None = None, parent=None): super(ResultsWidget, self).__init__(parent=parent) self.store_api = store_api + self.rcore = rcore + self._index = 0 self.results_container = QWidget(self) self.results_container.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed) - self.results_layout = FlowLayout(self.results_container) + self.results_layout = QGridLayout(self.results_container) + self.results_layout.setContentsMargins(0, 0, 0, 0) + self.results_layout.setSpacing(12) 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 _clear(self): + while self.results_layout.count(): + item = self.results_layout.takeAt(0) + widget = item.widget() + if widget is not None: + widget.deleteLater() + self._index = 0 + + def _add(self, widget: QWidget): + self.results_layout.addWidget(widget, self._index // self.COLUMNS, self._index % self.COLUMNS) + self._index += 1 + 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.FindChildOption.FindDirectChildrenOnly): - self.results_layout.removeWidget(w) - w.disconnect(w) - w.deleteLater() - for w in self.results_container.findChildren(SearchItemWidget, options=Qt.FindChildOption.FindDirectChildrenOnly): - self.results_layout.removeWidget(w) - w.disconnect(w) - w.deleteLater() - + self._clear() if not results: - self.results_layout.addWidget(QLabel(self.tr('No results found'))) + self.results_layout.addWidget(QLabel(self.tr('No results found')), 0, 0) else: for res in results: - w = SearchItemWidget(self.store_api.cached_manager, res, parent=self.results_container) + w = SearchItemWidget(self.store_api.image_manager, res, self.rcore, parent=self.results_container) + w.show_details.connect(self.show_details) + self._add(w) + self.setEnabled(True) + + def show_browse(self, results: list): + self._clear() + if not results: + self.results_layout.addWidget(QLabel(self.tr('No results found')), 0, 0) + else: + for game in results: + w = StoreItemWidget(self.store_api.image_manager, game, self.rcore, parent=self.results_container) w.show_details.connect(self.show_details) - self.results_layout.addWidget(w) - self.results_layout.update() + self._add(w) self.setEnabled(True) diff --git a/rare/components/tabs/store/widgets/details.py b/rare/components/tabs/store/widgets/details.py index 0b4ea5970..b10122233 100644 --- a/rare/components/tabs/store/widgets/details.py +++ b/rare/components/tabs/store/widgets/details.py @@ -18,6 +18,8 @@ from rare.components.tabs.store.api.models.response import CatalogOfferModel from rare.components.tabs.store.store_api import StoreAPI from rare.models.image import ImageSize +from rare.models.install import InstallOptionsModel +from rare.shared import RareCore from rare.ui.components.tabs.store.details import Ui_StoreDetailsWidget from rare.utils.misc import qta_icon from rare.widgets.elide_label import ElideLabel @@ -29,9 +31,10 @@ class StoreDetailsWidget(QWidget, SideTabContents): back_clicked: Signal = Signal() + open_library: Signal = Signal() # TODO Design - def __init__(self, installed: list, store_api: StoreAPI, parent=None): + def __init__(self, rcore: RareCore | None, store_api: StoreAPI, parent=None): super(StoreDetailsWidget, self).__init__(parent=parent) self.implements_scrollarea = True @@ -39,17 +42,18 @@ def __init__(self, installed: list, store_api: StoreAPI, parent=None): self.ui.setupUi(self) self.ui.main_layout.setContentsMargins(0, 0, 3, 0) + self.rcore = rcore self.store_api = store_api - self.installed = installed self.catalog_offer: CatalogOfferModel = None + self._primary_mode: str | None = None - self.image = LoadingSpinnerImageWidget(store_api.cached_manager, self) + self.image = LoadingSpinnerImageWidget(store_api.image_manager, self) self.image.setFixedSize(ImageSize.DisplayTall) self.ui.left_layout.insertWidget(0, self.image, alignment=Qt.AlignmentFlag.AlignTop) self.ui.left_layout.setAlignment(Qt.AlignmentFlag.AlignTop) self.ui.wishlist_button.clicked.connect(self.add_to_wishlist) - self.ui.store_button.clicked.connect(self.button_clicked) + self.ui.store_button.clicked.connect(self.on_primary_action) self.ui.wishlist_button.setVisible(True) self.in_wishlist = False self.wishlist = [] @@ -103,12 +107,7 @@ def update_game(self, offer: CatalogOfferModel): slug = slug.replace('/home', '') self.slug = slug - if offer.namespace in self.installed: - self.ui.store_button.setText(self.tr('Show Game on Epic Page')) - self.ui.status.setVisible(True) - else: - self.ui.store_button.setText(self.tr('Buy Game in Epic Games Store')) - self.ui.status.setVisible(False) + self._update_primary_action(offer) self.ui.original_price.setText(self.tr('Loading')) # self.title.setText(self.tr("Loading")) @@ -234,8 +233,108 @@ def data_received(self, product: DieselProduct): # for game in wishlist: # self.wishlist.append(game["offer"]["title"]) - def button_clicked(self): - QDesktopServices.openUrl(QUrl(f'https://www.epicgames.com/store/{self.store_api.language_code}/p/{self.slug}')) + def _app_name(self, offer: CatalogOfferModel) -> str: + if offer.items: + return offer.items[0].get('id') or offer.id + return offer.id + + def is_free(self, offer: CatalogOfferModel) -> bool: + if offer.price is None or offer.price.totalPrice is None: + return False + return offer.price.totalPrice.discountPrice == 0 + + def is_owned(self, offer: CatalogOfferModel) -> bool: + if self.rcore is None: + return False + app_name = self._app_name(offer) + if self.rcore.core().is_installed(app_name): + return True + entitlements = self.rcore.core().lgd.entitlements or [] + return any( + ent.get('namespace') == offer.namespace and ent.get('offerId') == offer.id + for ent in entitlements + ) + + def is_installed(self, offer: CatalogOfferModel) -> bool: + if self.rcore is None: + return False + return self.rcore.core().is_installed(self._app_name(offer)) + + def _update_primary_action(self, offer: CatalogOfferModel): + if self.is_installed(offer): + self.ui.store_button.setText(self.tr('In Library')) + self.ui.status.setText(self.tr('You own this game')) + self.ui.status.setVisible(True) + self._primary_mode = 'library' + elif self.is_owned(offer): + self.ui.store_button.setText(self.tr('Install')) + self.ui.status.setText(self.tr('You own this game')) + self.ui.status.setVisible(True) + self._primary_mode = 'install' + elif self.is_free(offer): + self.ui.store_button.setText(self.tr('Get it free')) + self.ui.status.setVisible(False) + self._primary_mode = 'claim' + else: + self.ui.store_button.setText(self.tr('Buy on Epic Games Store')) + self.ui.status.setVisible(False) + self._primary_mode = 'buy' + self.ui.store_button.setEnabled(True) + + @Slot() + def on_primary_action(self): + offer = self.catalog_offer + if offer is None or self._primary_mode is None: + return + + if self._primary_mode == 'library': + self.open_library.emit() + elif self._primary_mode == 'install': + self._install_offer(offer) + elif self._primary_mode == 'claim': + self._claim_offer(offer) + elif self._primary_mode == 'buy': + self._open_store_page() + + def _open_store_page(self): + QDesktopServices.openUrl( + QUrl(f'https://www.epicgames.com/store/{self.store_api.language_code}/p/{self.slug}') + ) + + def _install_offer(self, offer: CatalogOfferModel): + if self.rcore is None: + self._open_store_page() + return + app_name = self._app_name(offer) + self.ui.store_button.setEnabled(False) + self.ui.store_button.setText(self.tr('Installing...')) + self.rcore.signals().game.install.emit( + InstallOptionsModel( + app_name=app_name, + platform=self.rcore.core().default_platform, + ) + ) + self.open_library.emit() + + def _claim_offer(self, offer: CatalogOfferModel): + self.ui.store_button.setEnabled(False) + self.ui.store_button.setText(self.tr('Claiming...')) + self.store_api.purchase_free_game( + offer.namespace, + offer.id, + lambda success: self._on_claim_result(offer, success), + ) + + @Slot(bool) + def _on_claim_result(self, offer: CatalogOfferModel, success: bool): + if not success: + self.ui.store_button.setEnabled(True) + self.ui.store_button.setText(self.tr('Buy on Epic Games Store')) + self._primary_mode = 'buy' + self._open_store_page() + return + self._update_primary_action(offer) + self._install_offer(offer) def keyPressEvent(self, a0: QKeyEvent): if a0.key() == Qt.Key.Key_Escape: diff --git a/rare/components/tabs/store/widgets/filters.py b/rare/components/tabs/store/widgets/filters.py new file mode 100644 index 000000000..ca0495a42 --- /dev/null +++ b/rare/components/tabs/store/widgets/filters.py @@ -0,0 +1,127 @@ +from PySide6.QtCore import Signal +from PySide6.QtWidgets import ( + QButtonGroup, + QCheckBox, + QGroupBox, + QRadioButton, + QVBoxLayout, + QWidget, +) + +from rare.components.tabs.store.api.models.query import SearchStoreQuery +from rare.components.tabs.store.constants import Constants + + +class _FilterCheckBox(QCheckBox): + activated = Signal(str) + deactivated = Signal(str) + + def __init__(self, text: str, tag: str): + super().__init__(text) + self.tag = tag + self.toggled.connect(self._handle) + + def _handle(self, checked: bool): + if checked: + self.activated.emit(self.tag) + else: + self.deactivated.emit(self.tag) + + +class FiltersWidget(QWidget): + changed = Signal() + + def __init__(self, parent: QWidget | None = None): + super().__init__(parent=parent) + + self.price = '' + self.tags: list[str] = [] + self.on_sale = False + + self._all_price_rb: QRadioButton | None = None + + layout = QVBoxLayout(self) + layout.setContentsMargins(0, 0, 0, 0) + layout.setSpacing(6) + self._build_body(layout) + + def _build_body(self, layout: QVBoxLayout): + price_group = QGroupBox(self.tr('Price')) + price_layout = QVBoxLayout(price_group) + self._price_group = QButtonGroup(self) + self._price_group.setExclusive(True) + for text, value in [ + (self.tr('All'), ''), + (self.tr('Free'), 'free'), + (self.tr('Under 10'), '[0, 1000)'), + (self.tr('Under 20'), '[0, 2000)'), + (self.tr('Under 30'), '[0, 3000)'), + (self.tr('Above 15'), '[1499,]'), + ]: + rb = QRadioButton(text) + rb.setProperty('price', value) + if value == '': + rb.setChecked(True) + self._all_price_rb = rb + rb.toggled.connect(lambda checked, v=value: self._on_price(checked, v)) + self._price_group.addButton(rb) + price_layout.addWidget(rb) + layout.addWidget(price_group) + + self.on_sale_cb = QCheckBox(self.tr('On sale')) + self.on_sale_cb.toggled.connect(self._on_sale) + layout.addWidget(self.on_sale_cb) + + constants = Constants() + for title, items in [ + (self.tr('Genre'), constants.categories), + (self.tr('Platform'), constants.platforms), + (self.tr('Other'), constants.others), + (self.tr('Type'), constants.types), + ]: + group = QGroupBox(title) + group_layout = QVBoxLayout(group) + for text, tag in items: + cb = _FilterCheckBox(text, tag) + cb.activated.connect(self._on_tag_added) + cb.deactivated.connect(self._on_tag_removed) + group_layout.addWidget(cb) + layout.addWidget(group) + + def _on_price(self, checked: bool, value: str): + if checked: + self.price = value + self.changed.emit() + + def _on_sale(self, checked: bool): + self.on_sale = checked + self.changed.emit() + + def _on_tag_added(self, tag: str): + if tag not in self.tags: + self.tags.append(tag) + self.changed.emit() + + def _on_tag_removed(self, tag: str): + if tag in self.tags: + self.tags.remove(tag) + self.changed.emit() + + def reset(self): + self.price = '' + self.tags = [] + self.on_sale = False + if self._all_price_rb is not None: + self._all_price_rb.setChecked(True) + self.on_sale_cb.setChecked(False) + for cb in self.findChildren(_FilterCheckBox): + cb.setChecked(False) + self.changed.emit() + + def is_active(self) -> bool: + return bool(self.price) or self.on_sale or bool(self.tags) + + def configure(self, query: SearchStoreQuery): + query.price_range = self.price + query.on_sale = self.on_sale + query.tag = '|'.join(self.tags) if self.tags else '' diff --git a/rare/components/tabs/store/widgets/groups.py b/rare/components/tabs/store/widgets/groups.py index ad6994c1e..9a596a160 100644 --- a/rare/components/tabs/store/widgets/groups.py +++ b/rare/components/tabs/store/widgets/groups.py @@ -1,14 +1,68 @@ -from PySide6.QtWidgets import QGroupBox, QLayout +from PySide6.QtCore import QSize, Qt +from PySide6.QtWidgets import ( + QGridLayout, + QGroupBox, + QLabel, + QLayout, + QSizePolicy, + QWidget, +) from rare.widgets.loading_widget import LoadingWidget class StoreGroup(QGroupBox): - def __init__(self, title: str, layout: type[QLayout], parent=None): + COLUMNS = 3 + + def __init__(self, title: str, layout: type[QLayout] | None = None, parent=None): super().__init__(parent=parent) self.setTitle(title) - self.main_layout = layout(self) + self._count = 0 + + self.grid_widget = QWidget(self) + self.grid_widget.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding) + self._grid = QGridLayout(self.grid_widget) + self._grid.setContentsMargins(0, 0, 0, 0) + self._grid.setSpacing(12) + self.main_layout = self._grid + + self.empty_label = QLabel() + self.empty_label.setAlignment(Qt.AlignmentFlag.AlignCenter) + self.empty_label.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding) + self.empty_label.setVisible(False) + self.loading_widget = LoadingWidget(True, parent=self) + self.loading_widget.setFixedSize(QSize(48, 48)) + self.loading_widget.setVisible(False) + + root = QGridLayout(self) + root.setContentsMargins(0, 0, 0, 0) + root.setSpacing(0) + root.addWidget(self.grid_widget, 0, 0) + root.addWidget(self.empty_label, 0, 0, alignment=Qt.AlignmentFlag.AlignCenter) + root.addWidget(self.loading_widget, 0, 0, alignment=Qt.AlignmentFlag.AlignCenter) def loading(self, state: bool) -> None: self.loading_widget.setVisible(state) + if state: + self.empty_label.setVisible(False) + + def add_widget(self, widget: QWidget) -> None: + self.empty_label.setVisible(False) + row = self._count // self.COLUMNS + column = self._count % self.COLUMNS + self._grid.addWidget(widget, row, column) + self._count += 1 + + def clear_widgets(self) -> None: + while self._grid.count(): + item = self._grid.takeAt(0) + widget = item.widget() + if widget is not None: + widget.deleteLater() + self._count = 0 + + def set_empty(self, text: str) -> None: + self.empty_label.setText(text) + self.empty_label.setVisible(True) + self.loading_widget.setVisible(False) diff --git a/rare/components/tabs/store/widgets/items.py b/rare/components/tabs/store/widgets/items.py index 69233c068..05b6fa468 100644 --- a/rare/components/tabs/store/widgets/items.py +++ b/rare/components/tabs/store/widgets/items.py @@ -6,6 +6,7 @@ from rare.components.tabs.store.api.models.response import CatalogOfferModel from rare.models.image import ImageSize +from rare.shared import RareCore from rare.utils.misc import qta_icon from rare.utils.qrequests import QRequests from rare.widgets.image_widget import LoadingSpinnerImageWidget @@ -15,13 +16,30 @@ logger = getLogger('StoreWidgets') +def is_offer_owned(rcore: RareCore | None, game: CatalogOfferModel) -> bool: + if rcore is None or game is None: + return False + app_name = game.items[0].get('id') if game.items else game.id + try: + if rcore.core().is_installed(app_name): + return True + except Exception as e: # noqa: BLE001 + logger.debug('Ownership check failed for %s: %s', app_name, e) + entitlements = rcore.core().lgd.entitlements or [] + return any( + ent.get('namespace') == game.namespace and ent.get('offerId') == game.id + for ent in entitlements + ) + + class ItemWidgetSpinner(LoadingSpinnerImageWidget): show_details = Signal(CatalogOfferModel) - def __init__(self, manager: QRequests, catalog_game: CatalogOfferModel = None, parent=None): + def __init__(self, manager: QRequests, catalog_game: CatalogOfferModel = None, rcore: RareCore | None = None, parent=None): super(ItemWidgetSpinner, self).__init__(manager, parent=parent) self.ui = IconWidget() self.catalog_game = catalog_game + self.rcore = rcore def mousePressEvent(self, a0: QMouseEvent) -> None: if a0.button() == Qt.MouseButton.LeftButton: @@ -32,8 +50,8 @@ def mousePressEvent(self, a0: QMouseEvent) -> None: class StoreItemWidget(ItemWidgetSpinner): - def __init__(self, manager: QRequests, catalog_game: CatalogOfferModel = None, parent=None): - super(StoreItemWidget, self).__init__(manager, catalog_game, parent=parent) + def __init__(self, manager: QRequests, catalog_game: CatalogOfferModel = None, rcore: RareCore | None = None, parent=None): + super(StoreItemWidget, self).__init__(manager, catalog_game, rcore, parent=parent) self.setFixedSize(ImageSize.DisplayWide) self.ui.setupUi(self) if catalog_game: @@ -52,33 +70,29 @@ def init_ui(self, game: CatalogOfferModel): else: developer = game.seller['name'] self.ui.developer_label.setText(developer) - price = game.price.totalPrice.fmtPrice['originalPrice'] - discount_price = game.price.totalPrice.fmtPrice['discountPrice'] - self.ui.price_label.setText(f'{price if price != "0" else self.tr("Free")}') - if price != discount_price: - font = self.ui.price_label.font() - font.setStrikeOut(True) - self.ui.price_label.setFont(font) - self.ui.discount_label.setText(f'{discount_price if discount_price != "0" else self.tr("Free")}') - else: + + if is_offer_owned(self.rcore, game): + self.ui.price_label.setText(self.tr('Owned')) self.ui.discount_label.setVisible(False) + else: + price = game.price.totalPrice.fmtPrice['originalPrice'] + discount_price = game.price.totalPrice.fmtPrice['discountPrice'] + self.ui.price_label.setText(f'{price if price != "0" else self.tr("Free")}') + if price != discount_price: + font = self.ui.price_label.font() + font.setStrikeOut(True) + self.ui.price_label.setFont(font) + self.ui.discount_label.setText(f'{discount_price if discount_price != "0" else self.tr("Free")}') + else: + self.ui.discount_label.setVisible(False) key_images = game.keyImages self.fetchPixmap(key_images.for_dimensions(self.width(), self.height()).url) - # for img in json_info["keyImages"]: - # if img["type"] in ["DieselStoreFrontWide", "OfferImageWide", "VaultClosed", "ProductLogo"]: - # if img["type"] == "VaultClosed" and json_info["title"] != "Mystery Game": - # continue - # self.fetchPixmap(img["url"]) - # break - # else: - # logger.info(", ".join([img["type"] for img in json_info["keyImages"]])) - class SearchItemWidget(ItemWidgetSpinner): - def __init__(self, manager: QRequests, catalog_game: CatalogOfferModel, parent=None): - super(SearchItemWidget, self).__init__(manager, catalog_game, parent=parent) + def __init__(self, manager: QRequests, catalog_game: CatalogOfferModel, rcore: RareCore | None = None, parent=None): + super(SearchItemWidget, self).__init__(manager, catalog_game, rcore, parent=parent) self.setFixedSize(ImageSize.LibraryTall) self.ui.setupUi(self) @@ -87,23 +101,27 @@ def __init__(self, manager: QRequests, catalog_game: CatalogOfferModel, parent=N self.ui.title_label.setText(catalog_game.title) - price = catalog_game.price.totalPrice.fmtPrice['originalPrice'] - discount_price = catalog_game.price.totalPrice.fmtPrice['discountPrice'] - self.ui.price_label.setText(f'{price if price != "0" else self.tr("Free")}') - if price != discount_price: - font = self.ui.price_label.font() - font.setStrikeOut(True) - self.ui.price_label.setFont(font) - self.ui.discount_label.setText(f'{discount_price if discount_price != "0" else self.tr("Free")}') - else: + if is_offer_owned(self.rcore, catalog_game): + self.ui.price_label.setText(self.tr('Owned')) self.ui.discount_label.setVisible(False) + else: + price = catalog_game.price.totalPrice.fmtPrice['originalPrice'] + discount_price = catalog_game.price.totalPrice.fmtPrice['discountPrice'] + self.ui.price_label.setText(f'{price if price != "0" else self.tr("Free")}') + if price != discount_price: + font = self.ui.price_label.font() + font.setStrikeOut(True) + self.ui.price_label.setFont(font) + self.ui.discount_label.setText(f'{discount_price if discount_price != "0" else self.tr("Free")}') + else: + self.ui.discount_label.setVisible(False) class WishlistItemWidget(ItemWidgetSpinner): delete_from_wishlist = Signal(CatalogOfferModel) - def __init__(self, manager: QRequests, catalog_game: CatalogOfferModel, parent=None): - super(WishlistItemWidget, self).__init__(manager, catalog_game, parent=parent) + def __init__(self, manager: QRequests, catalog_game: CatalogOfferModel, rcore: RareCore | None = None, parent=None): + super(WishlistItemWidget, self).__init__(manager, catalog_game, rcore, parent=parent) self.setFixedSize(ImageSize.DisplayWide) self.ui.setupUi(self) @@ -118,14 +136,18 @@ def __init__(self, manager: QRequests, catalog_game: CatalogOfferModel, parent=N self.ui.title_label.setText(catalog_game.title) self.ui.developer_label.setText(developer) - self.ui.price_label.setText(f'{original_price if original_price != "0" else self.tr("Free")}') - if original_price != discount_price: - font = self.ui.price_label.font() - font.setStrikeOut(True) - self.ui.price_label.setFont(font) - self.ui.discount_label.setText(f'{discount_price if discount_price != "0" else self.tr("Free")}') - else: + if is_offer_owned(self.rcore, catalog_game): + self.ui.price_label.setText(self.tr('Owned')) self.ui.discount_label.setVisible(False) + else: + self.ui.price_label.setText(f'{original_price if original_price != "0" else self.tr("Free")}') + if original_price != discount_price: + font = self.ui.price_label.font() + font.setStrikeOut(True) + self.ui.price_label.setFont(font) + self.ui.discount_label.setText(f'{discount_price if discount_price != "0" else self.tr("Free")}') + else: + self.ui.discount_label.setVisible(False) key_images = catalog_game.keyImages self.fetchPixmap(key_images.for_dimensions(self.width(), self.height()).url) diff --git a/rare/components/tabs/store/wishlist.py b/rare/components/tabs/store/wishlist.py index 278ec3244..fd74a59b5 100644 --- a/rare/components/tabs/store/wishlist.py +++ b/rare/components/tabs/store/wishlist.py @@ -3,11 +3,11 @@ from PySide6.QtCore import Qt, Signal, Slot from PySide6.QtGui import QShowEvent -from PySide6.QtWidgets import QMessageBox, QSizePolicy, QWidget +from PySide6.QtWidgets import QGridLayout, QMessageBox, QSizePolicy, QWidget +from rare.shared import RareCore from rare.ui.components.tabs.store.wishlist import Ui_Wishlist from rare.utils.misc import qta_icon -from rare.widgets.flow_layout import FlowLayout from rare.widgets.side_tab import SideTabContents from rare.widgets.sliding_stack import SlidingStackedWidget @@ -18,19 +18,22 @@ class WishlistPage(SlidingStackedWidget, SideTabContents): - def __init__(self, api: StoreAPI, parent=None): + open_library = Signal() + + def __init__(self, api: StoreAPI, rcore: RareCore | None = None, parent=None): super(WishlistPage, self).__init__(parent=parent) self.implements_scrollarea = True - self.wishlist_widget = WishlistWidget(api, parent=self) + self.wishlist_widget = WishlistWidget(api, rcore, parent=self) self.wishlist_widget.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed) self.wishlist_widget.set_title.connect(self.set_title) self.wishlist_widget.show_details.connect(self.show_details) - self.details_widget = StoreDetailsWidget([], api, parent=self) + self.details_widget = StoreDetailsWidget(rcore, api, parent=self) self.details_widget.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed) self.details_widget.set_title.connect(self.set_title) self.details_widget.back_clicked.connect(self.show_main) + self.details_widget.open_library.connect(self.open_library) self.setDirection(Qt.Orientation.Horizontal) self.addWidget(self.wishlist_widget) @@ -62,15 +65,19 @@ class WishlistWidget(QWidget, SideTabContents): show_details = Signal(CatalogOfferModel) update_wishlist = Signal() - def __init__(self, api: StoreAPI, parent=None): + def __init__(self, api: StoreAPI, rcore: RareCore | None = None, parent=None): super(WishlistWidget, self).__init__(parent=parent) self.implements_scrollarea = True self.api = api + self.rcore = rcore self.ui = Ui_Wishlist() self.ui.setupUi(self) self.ui.main_layout.setContentsMargins(0, 0, 3, 0) - self.wishlist_layout = FlowLayout() + self.wishlist_layout = QGridLayout() + self.wishlist_layout.setContentsMargins(0, 0, 0, 0) + self.wishlist_layout.setSpacing(12) + self._w_index = 0 self.ui.container_layout.addLayout(self.wishlist_layout, stretch=1) filters = { @@ -138,17 +145,28 @@ def filter_wishlist(self, index: int = int(WishlistFilter.NONE)): - (x.catalog_game.price.totalPrice.discountPrice / x.catalog_game.price.totalPrice.originalPrice), } + def _clear_wishlist(self, delete: bool) -> None: + self._w_index = 0 + while self.wishlist_layout.count(): + item = self.wishlist_layout.takeAt(0) + widget = item.widget() + if widget is not None and delete: + widget.deleteLater() + + def _add_wishlist(self, widget: QWidget) -> None: + self.wishlist_layout.addWidget(widget, self._w_index // 3, self._w_index % 3) + self._w_index += 1 + @Slot(int) def order_wishlist(self, index: int = int(WishlistOrder.NAME)): list_order = self.ui.order_combo.itemData(index, Qt.ItemDataRole.UserRole) widgets = self.ui.container.findChildren(WishlistItemWidget, options=Qt.FindChildOption.FindDirectChildrenOnly) - for w in widgets: - self.wishlist_layout.removeWidget(w) + self._clear_wishlist(delete=False) reverse = self.ui.reverse_check.isChecked() widgets = sorted(widgets, key=self.__ordering[list_order], reverse=reverse) for w in widgets: - self.wishlist_layout.addWidget(w) + self._add_wishlist(w) @Slot(Qt.CheckState) def _on_reverse_changed(self, state: Qt.CheckState): @@ -156,20 +174,18 @@ def _on_reverse_changed(self, state: Qt.CheckState): @Slot(object) def set_wishlist(self, wishlist: list[WishlistItemModel] | None = None): - if wishlist and wishlist[0] == 'error': + if not wishlist or (isinstance(wishlist, list) and wishlist[0] == 'error'): + self._clear_wishlist(delete=True) + self.ui.no_games_label.setVisible(True) return - widgets = self.ui.container.findChildren(WishlistItemWidget, options=Qt.FindChildOption.FindDirectChildrenOnly) - for w in widgets: - self.wishlist_layout.removeWidget(w) - w.disconnect(w) - w.deleteLater() + self._clear_wishlist(delete=True) self.ui.no_games_label.setVisible(bool(wishlist)) widgets = [] for game in wishlist: - w = WishlistItemWidget(self.api.cached_manager, game.offer, self.ui.container) + w = WishlistItemWidget(self.api.image_manager, game.offer, self.rcore, self.ui.container) w.show_details.connect(self.show_details) w.delete_from_wishlist.connect(self.delete_from_wishlist) widgets.append(w) @@ -178,7 +194,7 @@ def set_wishlist(self, wishlist: list[WishlistItemModel] | None = None): reverse = self.ui.reverse_check.isChecked() widgets = sorted(widgets, key=self.__ordering[list_order], reverse=reverse) for w in widgets: - self.wishlist_layout.addWidget(w) + self._add_wishlist(w) self.filter_wishlist(self.ui.filter_combo.currentIndex()) diff --git a/rare/widgets/image_widget.py b/rare/widgets/image_widget.py index f337c4c73..c30eeb8b5 100644 --- a/rare/widgets/image_widget.py +++ b/rare/widgets/image_widget.py @@ -190,7 +190,7 @@ def __init__(self, manager: QRequests, parent=None): self.spinner = LoadingWidget(parent=self) def fetchPixmap(self, url: str, params: dict | None = None): - self.spinner.setFixedSize(self._image_size.size) + self.spinner.setFixedSize(QSize(48, 48)) self.spinner.setVisible(True) params = ( params if params else {'resize': 1, 'w': self._image_size.base.size.width(), 'h': self._image_size.base.size.height()} diff --git a/rare/widgets/loading_widget.py b/rare/widgets/loading_widget.py index fcad4be58..0f0b3ac89 100644 --- a/rare/widgets/loading_widget.py +++ b/rare/widgets/loading_widget.py @@ -8,8 +8,8 @@ def __init__(self, autostart: bool = False, *, parent: QWidget | None = None): super(LoadingWidget, self).__init__(parent=parent) self.setObjectName(type(self).__name__) self.setAlignment(Qt.AlignmentFlag.AlignHCenter | Qt.AlignmentFlag.AlignVCenter) - # The animation's exact size is 94x94 - self.setFixedSize(96, 96) + self.setScaledContents(True) + self.setFixedSize(48, 48) self.setMovie(QMovie(':/images/loader.webp', parent=self)) if parent is not None: parent.installEventFilter(self)