From 1721677e330e48cdb8119b47728743eb668b196d Mon Sep 17 00:00:00 2001 From: loathingKernel <142770+loathingKernel@users.noreply.github.com> Date: Tue, 7 Feb 2023 13:41:59 +0200 Subject: [PATCH] GameWidget: Implement reactive and interactive labels The `status_label` displays what is currently going on with the game. It reflects the current operation running on it or if it requires special attention (update, needs verification etc) The `tooltip_label` displays hover information such as what happens if a part of the widget is clicked or in the case of the launch button if the game can run (without version check, offline etc) The context menu on the widgets will be updated and populated according to the installation state of the game. Since the context menu was revised the shortcut creation code was revised too to make it more compact. the `create_desktop_link` and `get_rare_executable` functions are moved from `rare.utils.misc` to `rare.utils.paths` to avoid cyclical imports and better grouping. Two functions are added, `desktop_link_path` to uniformly calculate the path of the shortcut and `desktop_links_supported` which checks if Rare supports creating shortcuts on the current platform. `desktop_links_supported` should be used as safeguard before `desktop_link_path`. Desktop links are currently untested on Windows but if `shortcut.Description` works as expected, it should be good to go. --- rare/__main__.py | 6 +- rare/components/tabs/downloads/__init__.py | 23 +- rare/components/tabs/downloads/thread.py | 8 +- .../tabs/games/game_widgets/game_widget.py | 331 +++++++++--------- .../games/game_widgets/icon_game_widget.py | 21 +- .../tabs/games/game_widgets/icon_widget.py | 26 +- .../tabs/games/game_widgets/library_widget.py | 2 + .../games/game_widgets/list_game_widget.py | 61 +--- .../tabs/games/game_widgets/list_widget.py | 4 + rare/components/tabs/settings/rare.py | 43 +-- rare/models/game.py | 3 +- rare/utils/misc.py | 162 +-------- rare/utils/paths.py | 191 +++++++++- 13 files changed, 447 insertions(+), 434 deletions(-) diff --git a/rare/__main__.py b/rare/__main__.py index b4fda214..c052c7a9 100644 --- a/rare/__main__.py +++ b/rare/__main__.py @@ -65,13 +65,13 @@ def main(): args = parser.parse_args() if args.desktop_shortcut or args.startmenu_shortcut: - from rare.utils.misc import create_desktop_link + from rare.utils.paths import create_desktop_link if args.desktop_shortcut: - create_desktop_link(type_of_link="desktop", for_rare=True) + create_desktop_link(app_name="rare_shortcut", link_type="desktop") if args.startmenu_shortcut: - create_desktop_link(type_of_link="start_menu", for_rare=True) + create_desktop_link(app_name="rare_shortcut", link_type="start_menu") print("Link created") return diff --git a/rare/components/tabs/downloads/__init__.py b/rare/components/tabs/downloads/__init__.py index fb2878db..ce98aab3 100644 --- a/rare/components/tabs/downloads/__init__.py +++ b/rare/components/tabs/downloads/__init__.py @@ -1,4 +1,5 @@ import datetime +import platform from ctypes import c_uint64 from logging import getLogger from typing import Union, Optional @@ -18,7 +19,8 @@ from rare.models.install import InstallOptionsModel, InstallQueueItemModel, Unin from rare.shared import RareCore from rare.shared.workers.install_info import InstallInfoWorker from rare.shared.workers.uninstall import UninstallWorker -from rare.utils.misc import get_size, create_desktop_link +from rare.utils.misc import get_size +from rare.utils.paths import create_desktop_link, desktop_links_supported from .download import DownloadWidget from .groups import UpdateGroup, QueueGroup from .thread import DlThread, DlResultModel, DlResultCode @@ -220,15 +222,18 @@ class DownloadsTab(QWidget): @pyqtSlot(DlResultModel) def __on_download_result(self, result: DlResultModel): if result.code == DlResultCode.FINISHED: - if result.shortcuts: - if not create_desktop_link(result.options.app_name, self.core, "desktop"): + logger.info(f"Download finished: {result.options.app_name}") + if result.shortcut and desktop_links_supported(): + if not create_desktop_link( + app_name=result.options.app_name, + app_title=result.shortcut_title, + link_name=result.shortcut_name, + link_type="desktop", + ): # maybe add it to download summary, to show in finished downloads - pass + logger.error(f"Failed to create desktop link on {platform.system()}") else: - logger.info("Desktop shortcut written") - logger.info( - f"Download finished: {result.options.app_name}" - ) + logger.info(f"Created desktop link {result.shortcut_name} for {result.options.app_name}") if result.options.overlay: self.signals.application.overlay_installed.emit() @@ -239,8 +244,8 @@ class DownloadsTab(QWidget): self.updates_group.set_widget_enabled(result.options.app_name, True) elif result.code == DlResultCode.ERROR: - QMessageBox.warning(self, self.tr("Error"), self.tr("Download error: {}").format(result.message)) logger.error(f"Download error: {result.options.app_name} ({result.message})") + QMessageBox.warning(self, self.tr("Error"), self.tr("Download error: {}").format(result.message)) elif result.code == DlResultCode.STOPPED: logger.info(f"Download stopped: {result.options.app_name}") diff --git a/rare/components/tabs/downloads/thread.py b/rare/components/tabs/downloads/thread.py index 43fecfb0..a13b6dd9 100644 --- a/rare/components/tabs/downloads/thread.py +++ b/rare/components/tabs/downloads/thread.py @@ -34,7 +34,9 @@ class DlResultModel: dlcs: Optional[List[Dict]] = None sync_saves: bool = False tip_url: str = "" - shortcuts: bool = False + shortcut: bool = False + shortcut_name: str = "" + shortcut_title: str = "" class DlThread(QThread): @@ -149,7 +151,9 @@ class DlThread(QThread): ) if not self.item.options.update and self.item.options.create_shortcut: - result.shortcuts = True + result.shortcut = True + result.shortcut_name = self.rgame.folder_name + result.shortcut_title = self.rgame.app_title self.__result_emit(result) diff --git a/rare/components/tabs/games/game_widgets/game_widget.py b/rare/components/tabs/games/game_widgets/game_widget.py index 51afed28..1fa69b83 100644 --- a/rare/components/tabs/games/game_widgets/game_widget.py +++ b/rare/components/tabs/games/game_widgets/game_widget.py @@ -1,9 +1,8 @@ -import os import platform -from abc import abstractmethod +from abc import ABCMeta from logging import getLogger -from PyQt5.QtCore import pyqtSignal, QStandardPaths, Qt, pyqtSlot +from PyQt5.QtCore import pyqtSignal, Qt, pyqtSlot, QObject, QEvent from PyQt5.QtGui import QMouseEvent from PyQt5.QtWidgets import QMessageBox, QAction, QLabel, QPushButton @@ -14,12 +13,19 @@ from rare.shared import ( ArgumentsSingleton, ImageManagerSingleton, ) -from rare.utils.misc import create_desktop_link +from rare.utils.paths import desktop_links_supported, desktop_link_path, create_desktop_link from .library_widget import LibraryWidget logger = getLogger("GameWidget") +class GameWidgetUi(metaclass=ABCMeta): + status_label: QLabel + install_btn: QPushButton + launch_btn: QPushButton + tooltip_label: QLabel + + class GameWidget(LibraryWidget): show_info = pyqtSignal(RareGame) @@ -33,86 +39,41 @@ class GameWidget(LibraryWidget): self.rgame: RareGame = rgame self.setContextMenuPolicy(Qt.ActionsContextMenu) - if self.rgame.is_installed or self.rgame.is_origin: - self.launch_action = QAction(self.tr("Launch"), self) - self.launch_action.triggered.connect(self._launch) - self.addAction(self.launch_action) - else: - self.install_action = QAction(self.tr("Install"), self) - self.install_action.triggered.connect(self._install) - self.addAction(self.install_action) - # if self.rgame.game.supports_cloud_saves: - # sync = QAction(self.tr("Sync with cloud"), self) - # sync.triggered.connect(self.sync_game) - # self.addAction(sync) + self.launch_action = QAction(self.tr("Launch"), self) + self.launch_action.triggered.connect(self._launch) - desktop = QStandardPaths.writableLocation(QStandardPaths.DesktopLocation) - if os.path.exists( - os.path.join(desktop, f"{self.rgame.app_title}.desktop") - ) or os.path.exists(os.path.join(desktop, f"{self.rgame.app_title}.lnk")): - self.create_desktop = QAction(self.tr("Remove Desktop link")) - else: - self.create_desktop = QAction(self.tr("Create Desktop link")) - if self.rgame.is_installed: - self.create_desktop.triggered.connect( - lambda: self.create_desktop_link("desktop") - ) - self.addAction(self.create_desktop) + self.install_action = QAction(self.tr("Install"), self) + self.install_action.triggered.connect(self._install) - applications = QStandardPaths.writableLocation(QStandardPaths.ApplicationsLocation) - if platform.system() == "Linux": - start_menu_file = os.path.join(applications, f"{self.rgame.app_title}.desktop") - elif platform.system() == "Windows": - start_menu_file = os.path.join(applications, "..", f"{self.rgame.app_title}.lnk") - else: - start_menu_file = "" - if platform.system() in ["Windows", "Linux"]: - if os.path.exists(start_menu_file): - self.create_start_menu = QAction(self.tr("Remove start menu link")) - else: - self.create_start_menu = QAction(self.tr("Create start menu link")) - if self.rgame.is_installed: - self.create_start_menu.triggered.connect( - lambda: self.create_desktop_link("start_menu") - ) - self.addAction(self.create_start_menu) + # self.sync_action = QAction(self.tr("Sync with cloud"), self) + # self.sync_action.triggered.connect(self.sync_saves) - reload_image = QAction(self.tr("Reload Image"), self) - reload_image.triggered.connect(self._on_reload_image) - self.addAction(reload_image) + self.desktop_link_action = QAction(self) + self.desktop_link_action.triggered.connect( + lambda: self._create_link(self.rgame.folder_name, "desktop") + ) - if self.rgame.is_installed and not self.rgame.is_origin: - self.uninstall_action = QAction(self.tr("Uninstall"), self) - self.uninstall_action.triggered.connect(self._uninstall) - self.addAction(self.uninstall_action) + self.menu_link_action = QAction(self) + self.menu_link_action.triggered.connect( + lambda: self._create_link(self.rgame.folder_name, "start_menu") + ) - self.texts = { - "hover": { - "update_available": self.tr("Start without version check"), - "launch": self.tr("Launch Game"), - "launch_origin": self.tr("Launch/Link"), - "running": self.tr("Game running"), - "launch_offline": self.tr("Launch offline"), - "no_launch": self.tr("Can't launch game") - }, - "status": { - "needs_verification": self.tr("Please verify game before playing"), - "running": self.tr("Game running"), - "syncing": self.tr("Syncing cloud saves"), - "update_available": self.tr("Update available"), - "no_meta": self.tr("Game is only offline available"), - "no_launch": self.tr("Can't launch game"), - }, - } + self.reload_action = QAction(self.tr("Reload Image"), self) + self.reload_action.triggered.connect(self._on_reload_image) + + self.uninstall_action = QAction(self.tr("Uninstall"), self) + self.uninstall_action.triggered.connect(self._uninstall) + + self.update_actions() # signals - self.rgame.signals.widget.update.connect( - lambda: self.setPixmap(self.rgame.pixmap) - ) - self.rgame.signals.widget.update.connect( - self.update_widget - ) + self.rgame.signals.widget.update.connect(lambda: self.setPixmap(self.rgame.pixmap)) + self.rgame.signals.widget.update.connect(self.update_buttons) + self.rgame.signals.widget.update.connect(self.update_state) + self.rgame.signals.game.installed.connect(self.update_actions) + self.rgame.signals.game.uninstalled.connect(self.update_actions) + self.rgame.signals.progress.start.connect( lambda: self.showProgress( self.image_manager.get_pixmap(self.rgame.app_name, True), @@ -125,67 +86,113 @@ class GameWidget(LibraryWidget): self.rgame.signals.progress.finish.connect( lambda e: self.hideProgress(e) ) - self.rgame.signals.progress.finish.connect(self.set_status) - @abstractmethod - def set_status(self, label: QLabel): - if self.rgame.is_installed: + self.state_strings = { + RareGame.State.IDLE: "", + RareGame.State.RUNNING: self.tr("Running..."), + RareGame.State.DOWNLOADING: self.tr("Downloading..."), + RareGame.State.VERIFYING: self.tr("Verifying..."), + RareGame.State.MOVING: self.tr("Moving..."), + RareGame.State.UNINSTALLING: self.tr("Uninstalling..."), + "has_update": self.tr("Update available"), + "needs_verification": self.tr("Needs verification"), + "not_can_launch": self.tr("Can't launch"), + } + + self.hover_strings = { + "info": self.tr("Show infromation"), + "install": self.tr("Install game"), + "can_launch": self.tr("Launch game"), + "is_foreign": self.tr("Launch offline"), + "has_update": self.tr("Launch without version check"), + "is_origin": self.tr("Launch/Link"), + "not_can_launch": self.tr("Can't launch"), + } + + # lk: abstract class for typing, the `self.ui` attribute should be used + # lk: by the Ui class in the children. It must contain at least the same + # lk: attributes as `GameWidgetUi` class + self.ui = GameWidgetUi() + + @pyqtSlot() + def update_state(self): + if self.rgame.is_idle: if self.rgame.has_update: - label.setText(self.texts["status"]["update_available"]) - return - if self.rgame.needs_verification: - label.setText(self.texts["status"]["needs_verification"]) - return - label.setText("") - label.setVisible(False) - - @abstractmethod - def update_widget(self, install_btn: QPushButton, launch_btn: QPushButton): - install_btn.setVisible(not self.rgame.is_installed) - install_btn.setEnabled(not self.rgame.is_installed) - launch_btn.setVisible(self.rgame.is_installed) - launch_btn.setEnabled(self.rgame.can_launch) - - @property - def enterEventText(self) -> str: - if self.rgame.is_installed: - if self.rgame.state == RareGame.State.RUNNING: - return self.texts["status"]["running"] - elif (not self.rgame.is_origin) and self.rgame.needs_verification: - return self.texts["status"]["needs_verification"] - elif self.rgame.is_foreign: - return self.texts["hover"]["launch_offline"] - elif self.rgame.has_update: - return self.texts["hover"]["update_available"] + self.ui.status_label.setText(self.state_strings["has_update"]) + elif self.rgame.needs_verification: + self.ui.status_label.setText(self.state_strings["needs_verification"]) + elif not self.rgame.can_launch and self.rgame.is_installed: + self.ui.status_label.setText(self.state_strings["not_can_launch"]) else: - return self.tr("Game Info") - # return self.texts["hover"]["launch" if self.igame else "launch_origin"] + self.ui.status_label.setText(self.state_strings[self.rgame.state]) else: - if not self.rgame.state == RareGame.State.DOWNLOADING: - return self.tr("Game Info") - else: - return self.tr("Installation running") + self.ui.status_label.setText(self.state_strings[self.rgame.state]) + self.ui.status_label.setVisible(bool(self.ui.status_label.text())) - @property - def leaveEventText(self) -> str: - if self.rgame.is_installed: - if self.rgame.state == RareGame.State.RUNNING: - return self.texts["status"]["running"] - # elif self.syncing_cloud_saves: - # return self.texts["status"]["syncing"] - elif self.rgame.is_foreign: - return self.texts["status"]["no_meta"] - elif self.rgame.has_update: - return self.texts["status"]["update_available"] - elif (not self.rgame.is_origin) and self.rgame.needs_verification: - return self.texts["status"]["needs_verification"] - else: - return "" + @pyqtSlot() + def update_buttons(self): + self.ui.install_btn.setVisible(not self.rgame.is_installed) + self.ui.install_btn.setEnabled(not self.rgame.is_installed) + self.ui.launch_btn.setVisible(self.rgame.is_installed) + self.ui.launch_btn.setEnabled(self.rgame.can_launch) + + @pyqtSlot() + def update_actions(self): + for action in self.actions(): + self.removeAction(action) + + if self.rgame.is_installed or self.rgame.is_origin: + self.addAction(self.launch_action) else: - if self.rgame.state == RareGame.State.DOWNLOADING: - return "Installation..." + self.addAction(self.install_action) + + # if self.rgame.game.supports_cloud_saves: + # self.addAction(self.sync_action) + + if desktop_links_supported() and self.rgame.is_installed: + if desktop_link_path(self.rgame.folder_name, "desktop").exists(): + self.desktop_link_action.setText(self.tr("Remove Desktop link")) else: - return "" + self.desktop_link_action.setText(self.tr("Create Desktop link")) + self.addAction(self.desktop_link_action) + if desktop_link_path(self.rgame.folder_name, "start_menu").exists(): + self.menu_link_action.setText(self.tr("Remove Menu link")) + else: + self.menu_link_action.setText(self.tr("Create Menu link")) + self.addAction(self.menu_link_action) + + self.addAction(self.reload_action) + if self.rgame.is_installed and not self.rgame.is_origin: + self.addAction(self.uninstall_action) + + def eventFilter(self, a0: QObject, a1: QEvent) -> bool: + if a0 is self.ui.launch_btn: + if a1.type() == QEvent.Enter: + if not self.rgame.can_launch: + self.ui.tooltip_label.setText(self.hover_strings["not_can_launch"]) + elif self.rgame.is_origin: + self.ui.tooltip_label.setText(self.hover_strings["is_origin"]) + elif self.rgame.has_update: + self.ui.tooltip_label.setText(self.hover_strings["has_update"]) + elif self.rgame.is_foreign and self.rgame.can_run_offline: + self.ui.tooltip_label.setText(self.hover_strings["is_foreign"]) + elif self.rgame.can_launch: + self.ui.tooltip_label.setText(self.hover_strings["can_launch"]) + return True + if a1.type() == QEvent.Leave: + self.ui.tooltip_label.setText(self.hover_strings["info"]) + # return True + if a0 is self.ui.install_btn: + if a1.type() == QEvent.Enter: + self.ui.tooltip_label.setText(self.hover_strings["install"]) + return True + if a1.type() == QEvent.Leave: + self.ui.tooltip_label.setText(self.hover_strings["info"]) + # return True + if a0 is self: + if a1.type() == QEvent.Enter: + self.ui.tooltip_label.setText(self.hover_strings["info"]) + return super().eventFilter(a0, a1) def mousePressEvent(self, e: QMouseEvent) -> None: # left button @@ -220,46 +227,42 @@ class GameWidget(LibraryWidget): def _uninstall(self): self.show_info.emit(self.rgame) - def create_desktop_link(self, type_of_link): - if type_of_link == "desktop": - shortcut_path = QStandardPaths.writableLocation(QStandardPaths.DesktopLocation) - elif type_of_link == "start_menu": - shortcut_path = QStandardPaths.writableLocation(QStandardPaths.ApplicationsLocation) - else: - return - - if platform.system() == "Windows": - shortcut_path = os.path.join(shortcut_path, f"{self.rgame.app_title}.lnk") - elif platform.system() == "Linux": - shortcut_path = os.path.join(shortcut_path, f"{self.rgame.app_title}.desktop") - else: + def _create_link(self, name, link_type): + if not desktop_links_supported(): QMessageBox.warning( self, - "Warning", - f"Create a Desktop link is currently not supported on {platform.system()}", + self.tr("Warning"), + self.tr("Creating shortcuts is currently unsupported on {}").format(platform.system()), ) return - if not os.path.exists(shortcut_path): - try: - if not create_desktop_link(self.rgame.app_name, self.core, type_of_link): - return - except PermissionError: - QMessageBox.warning( - self, "Error", "Permission error. Cannot create Desktop Link" - ) - if type_of_link == "desktop": - self.create_desktop.setText(self.tr("Remove Desktop link")) - elif type_of_link == "start_menu": - self.create_start_menu.setText(self.tr("Remove Start menu link")) - else: - if os.path.exists(shortcut_path): - os.remove(shortcut_path) + shortcut_path = desktop_link_path(name, link_type) - if type_of_link == "desktop": - self.create_desktop.setText(self.tr("Create Desktop link")) - elif type_of_link == "start_menu": - self.create_start_menu.setText(self.tr("Create Start menu link")) + if not shortcut_path.exists(): + try: + if not create_desktop_link( + app_name=self.rgame.app_name, + app_title=self.rgame.app_title, + link_name=self.rgame.folder_name, + link_type=link_type, + ): + raise PermissionError + except PermissionError: + QMessageBox.warning(self, "Error", "Could not create shortcut.") + return + + if link_type == "desktop": + self.desktop_link_action.setText(self.tr("Remove Desktop link")) + elif link_type == "start_menu": + self.menu_link_action.setText(self.tr("Remove Start Menu link")) + else: + if shortcut_path.exists(): + shortcut_path.unlink(missing_ok=True) + + if link_type == "desktop": + self.desktop_link_action.setText(self.tr("Create Desktop link")) + elif link_type == "start_menu": + self.menu_link_action.setText(self.tr("Create Start Menu link")) # def sync_finished(self, app_name): # self.syncing_cloud_saves = False diff --git a/rare/components/tabs/games/game_widgets/icon_game_widget.py b/rare/components/tabs/games/game_widgets/icon_game_widget.py index 9f75ea78..13fc7374 100644 --- a/rare/components/tabs/games/game_widgets/icon_game_widget.py +++ b/rare/components/tabs/games/game_widgets/icon_game_widget.py @@ -1,7 +1,7 @@ from logging import getLogger from typing import Optional -from PyQt5.QtCore import QEvent, pyqtSlot +from PyQt5.QtCore import QEvent from rare.models.game import RareGame from rare.shared.image_manager import ImageSize @@ -13,13 +13,13 @@ logger = getLogger("IconGameWidget") class IconGameWidget(GameWidget): def __init__(self, rgame: RareGame, parent=None): - super(IconGameWidget, self).__init__(rgame, parent) + super().__init__(rgame, parent) self.setObjectName(f"{rgame.app_name}") self.setFixedSize(ImageSize.Display) self.ui = IconWidget() self.ui.setupUi(self) - self.ui.title_label.setText(f"

{self.rgame.app_title}

") + self.ui.title_label.setText(self.rgame.app_title) self.ui.launch_btn.clicked.connect(self._launch) self.ui.launch_btn.setVisible(self.rgame.is_installed) self.ui.install_btn.clicked.connect(self._install) @@ -27,27 +27,22 @@ class IconGameWidget(GameWidget): self.ui.launch_btn.setEnabled(self.rgame.can_launch) - self.set_status() + self.update_state() - @pyqtSlot() - def set_status(self): - super(IconGameWidget, self).set_status(self.ui.status_label) - - @pyqtSlot() - def update_widget(self): - super(IconGameWidget, self).update_widget(self.ui.install_btn, self.ui.launch_btn) + # lk: "connect" the buttons' enter/leave events to this widget + self.installEventFilter(self) + self.ui.launch_btn.installEventFilter(self) + self.ui.install_btn.installEventFilter(self) def enterEvent(self, a0: Optional[QEvent] = None) -> None: if a0 is not None: a0.accept() - self.ui.tooltip_label.setText(self.enterEventText) self.ui.enterAnimation(self) def leaveEvent(self, a0: Optional[QEvent] = None) -> None: if a0 is not None: a0.accept() self.ui.leaveAnimation(self) - self.ui.tooltip_label.setText(self.leaveEventText) # def sync_finished(self, app_name): # if not app_name == self.rgame.app_name: diff --git a/rare/components/tabs/games/game_widgets/icon_widget.py b/rare/components/tabs/games/game_widgets/icon_widget.py index e0572e83..8f055a3e 100644 --- a/rare/components/tabs/games/game_widgets/icon_widget.py +++ b/rare/components/tabs/games/game_widgets/icon_widget.py @@ -17,15 +17,15 @@ from rare.widgets.elide_label import ElideLabel class IconWidget(object): def __init__(self): self._effect = None - self._animation = None + self._animation: QPropertyAnimation = None - self.status_label = None - self.mini_widget = None - self.mini_effect = None - self.title_label = None - self.tooltip_label = None - self.launch_btn = None - self.install_btn = None + self.status_label: ElideLabel = None + self.mini_widget: QWidget = None + self.mini_effect: QGraphicsOpacityEffect = None + self.title_label: QLabel = None + self.tooltip_label: ElideLabel = None + self.launch_btn: QPushButton = None + self.install_btn: QPushButton = None def setupUi(self, widget: QWidget): # information at top @@ -34,6 +34,7 @@ class IconWidget(object): self.status_label.setStyleSheet( f"QLabel#{self.status_label.objectName()}" "{" + "font-weight: bold;" "color: white;" "background-color: rgba(0, 0, 0, 65%);" "border-radius: 5%;" @@ -68,11 +69,12 @@ class IconWidget(object): self.title_label.setStyleSheet( f"QLabel#{self.title_label.objectName()}" "{" + "font-weight: bold;" "background-color: rgba(0, 0, 0, 0%); color: white;" "}" ) self.title_label.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding) - self.title_label.setAlignment(Qt.AlignTop) + self.title_label.setAlignment(Qt.AlignVCenter) self.title_label.setAutoFillBackground(False) self.title_label.setWordWrap(True) @@ -125,12 +127,6 @@ class IconWidget(object): self.install_btn.setFixedSize(QSize(widget.width() // 4, widget.width() // 4)) # Create layouts - # layout for the whole widget, holds the image - layout = QVBoxLayout() - layout.setAlignment(Qt.AlignHCenter | Qt.AlignVCenter) - layout.setContentsMargins(0, 0, 0, 0) - layout.setSizeConstraint(QVBoxLayout.SetFixedSize) - # layout for the image, holds the mini widget and a spacer item image_layout = QVBoxLayout() image_layout.setContentsMargins(2, 2, 2, 2) diff --git a/rare/components/tabs/games/game_widgets/library_widget.py b/rare/components/tabs/games/game_widgets/library_widget.py index d869e797..a2aaebdf 100644 --- a/rare/components/tabs/games/game_widgets/library_widget.py +++ b/rare/components/tabs/games/game_widgets/library_widget.py @@ -37,6 +37,7 @@ class ProgressLabel(QLabel): def setStyleSheetColors(self, bg: QColor, fg: QColor, brd: QColor): sheet = ( + f"QLabel#{type(self).__name__} {{" f"background-color: rgba({bg.red()}, {bg.green()}, {bg.blue()}, 65%);" f"color: rgb({fg.red()}, {fg.green()}, {fg.blue()});" f"border-width: 1px;" @@ -44,6 +45,7 @@ class ProgressLabel(QLabel): f"border-color: rgb({brd.red()}, {brd.green()}, {brd.blue()});" f"font-weight: bold;" f"font-size: 16pt;" + f"}}" ) self.setStyleSheet(sheet) diff --git a/rare/components/tabs/games/game_widgets/list_game_widget.py b/rare/components/tabs/games/game_widgets/list_game_widget.py index 50825eb9..a3acf574 100644 --- a/rare/components/tabs/games/game_widgets/list_game_widget.py +++ b/rare/components/tabs/games/game_widgets/list_game_widget.py @@ -1,13 +1,15 @@ from logging import getLogger -from PyQt5.QtCore import Qt, QEvent, QRect, pyqtSlot, pyqtSignal +from PyQt5.QtCore import Qt, QEvent, QRect from PyQt5.QtGui import ( QPalette, QBrush, QPaintEvent, QPainter, QLinearGradient, - QPixmap, QImage, QResizeEvent, + QPixmap, + QImage, + QResizeEvent, ) from rare.models.game import RareGame @@ -20,72 +22,45 @@ logger = getLogger("ListGameWidget") class ListGameWidget(GameWidget): - def __init__(self, rgame: RareGame, parent=None): - super(ListGameWidget, self).__init__(rgame, parent) + super().__init__(rgame, parent) self.setObjectName(f"{rgame.app_name}") self.ui = ListWidget() self.ui.setupUi(self) - self.ui.title_label.setText(f"

{self.rgame.app_title}

") - - self.ui.install_btn.setVisible(not self.rgame.is_installed) + self.ui.title_label.setText(self.rgame.app_title) + self.ui.launch_btn.clicked.connect(self._launch) + self.ui.launch_btn.setVisible(self.rgame.is_installed) self.ui.install_btn.clicked.connect(self._install) + self.ui.install_btn.setVisible(not self.rgame.is_installed) + + self.ui.launch_btn.setEnabled(self.rgame.can_launch) self.ui.launch_btn.setText( self.tr("Launch") if not self.rgame.is_origin else self.tr("Link/Play") ) - self.ui.launch_btn.clicked.connect(self._launch) - self.ui.launch_btn.setVisible(self.rgame.is_installed) - self.ui.developer_text.setText(self.rgame.developer) # self.version_label.setVisible(self.is_installed) if self.rgame.igame: self.ui.version_text.setText(self.rgame.version) - self.ui.size_text.setText(get_size(self.rgame.install_size) if self.rgame.install_size else "") - self.ui.launch_btn.setEnabled(self.rgame.can_launch) + self.update_state() - self.set_status() - - @pyqtSlot() - def set_status(self): - super(ListGameWidget, self).set_status(self.ui.status_label) - - @pyqtSlot() - def update_widget(self): - super(ListGameWidget, self).update_widget(self.ui.install_btn, self.ui.launch_btn) - - def update_text(self, e=None): - if self.rgame.is_installed: - if self.rgame.has_update: - self.ui.status_label.setText(self.texts["status"]["update_available"]) - elif self.rgame.is_foreign: - self.ui.status_label.setText(self.texts["status"]["no_meta"]) - elif self.rgame.igame and self.rgame.needs_verification: - self.ui.status_label.setText(self.texts["status"]["needs_verification"]) - # elif self.syncing_cloud_saves: - # self.ui.status_label.setText(self.texts["status"]["syncing"]) - else: - self.ui.status_label.setText("") - self.ui.status_label.setVisible(False) - else: - self.ui.status_label.setVisible(False) + # lk: "connect" the buttons' enter/leave events to this widget + self.installEventFilter(self) + self.ui.launch_btn.installEventFilter(self) + self.ui.install_btn.installEventFilter(self) def enterEvent(self, a0: QEvent = None) -> None: if a0 is not None: a0.accept() - status = self.enterEventText - self.ui.tooltip_label.setText(status) - self.ui.tooltip_label.setVisible(bool(status)) + self.ui.tooltip_label.setVisible(True) def leaveEvent(self, a0: QEvent = None) -> None: if a0 is not None: a0.accept() - status = self.leaveEventText - self.ui.tooltip_label.setText(status) - self.ui.tooltip_label.setVisible(bool(status)) + self.ui.tooltip_label.setVisible(False) # def game_started(self, app_name): # if app_name == self.rgame.app_name: diff --git a/rare/components/tabs/games/game_widgets/list_widget.py b/rare/components/tabs/games/game_widgets/list_widget.py index e5bdbf5c..ae7a5a85 100644 --- a/rare/components/tabs/games/game_widgets/list_widget.py +++ b/rare/components/tabs/games/game_widgets/list_widget.py @@ -29,10 +29,14 @@ class ListWidget(object): def setupUi(self, widget: QWidget): self.title_label = QLabel(parent=widget) self.title_label.setWordWrap(False) + self.title_label.setStyleSheet( + "font-weight: bold;" + ) self.status_label = QLabel(parent=widget) self.status_label.setAlignment(Qt.AlignRight | Qt.AlignVCenter) self.status_label.setStyleSheet( + "font-weight: bold;" "background-color: rgba(0,0,0,75%);" "border: 1px solid black;" "border-radius: 5px;" diff --git a/rare/components/tabs/settings/rare.py b/rare/components/tabs/settings/rare.py index 44b1f407..84009dcf 100644 --- a/rare/components/tabs/settings/rare.py +++ b/rare/components/tabs/settings/rare.py @@ -4,13 +4,12 @@ import subprocess import sys from logging import getLogger -from PyQt5.QtCore import QSettings, QStandardPaths, Qt +from PyQt5.QtCore import QSettings, Qt from PyQt5.QtWidgets import QWidget, QMessageBox -from rare.shared import LegendaryCoreSingleton from rare.components.tabs.settings.widgets.rpc import RPCSettings +from rare.shared import LegendaryCoreSingleton from rare.ui.components.tabs.settings.rare import Ui_RareSettings -from rare.utils.paths import log_dir from rare.utils.misc import ( get_translations, get_color_schemes, @@ -18,8 +17,8 @@ from rare.utils.misc import ( get_style_sheets, set_style_sheet, get_size, - create_desktop_link, ) +from rare.utils.paths import create_desktop_link, desktop_link_path, log_dir, desktop_links_supported logger = getLogger("RareSettings") @@ -106,36 +105,28 @@ class RareSettings(QWidget, Ui_RareSettings): ) ) self.notification.stateChanged.connect( - lambda: self.settings.setValue( - "notification", self.notification.isChecked() - ) + lambda: self.settings.setValue("notification", self.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()) ) - desktop = QStandardPaths.writableLocation(QStandardPaths.DesktopLocation) - applications = QStandardPaths.writableLocation(QStandardPaths.ApplicationsLocation) - if platform.system() == "Linux": - self.desktop_file = os.path.join(desktop, "Rare.desktop") - self.start_menu_link = os.path.join(applications, "Rare.desktop") - elif platform.system() == "Windows": - self.desktop_file = os.path.join(desktop, "Rare.lnk") - self.start_menu_link = os.path.join(applications, "..", "Rare.lnk") + 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.setText(self.tr("Not supported")) + self.desktop_link_btn.setToolTip(self.tr("Not supported")) self.desktop_link_btn.setDisabled(True) - self.startmenu_link_btn.setText(self.tr("Not supported")) + self.startmenu_link_btn.setToolTip(self.tr("Not supported")) self.startmenu_link_btn.setDisabled(True) - - self.desktop_file = "" + self.desktop_link = "" self.start_menu_link = "" - if self.desktop_file and os.path.exists(self.desktop_file): + if self.desktop_link and self.desktop_link.exists(): self.desktop_link_btn.setText(self.tr("Remove desktop link")) - if self.start_menu_link and os.path.exists(self.start_menu_link): + if self.start_menu_link and self.start_menu_link.exists(): self.startmenu_link_btn.setText(self.tr("Remove start menu link")) self.desktop_link_btn.clicked.connect(self.create_desktop_link) @@ -171,7 +162,8 @@ class RareSettings(QWidget, Ui_RareSettings): def create_start_menu_link(self): try: if not os.path.exists(self.start_menu_link): - create_desktop_link(type_of_link="start_menu", for_rare=True) + 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")) else: os.remove(self.start_menu_link) @@ -186,11 +178,12 @@ class RareSettings(QWidget, Ui_RareSettings): def create_desktop_link(self): try: - if not os.path.exists(self.desktop_file): - create_desktop_link(type_of_link="desktop", for_rare=True) + 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")) else: - os.remove(self.desktop_file) + os.remove(self.desktop_link) self.desktop_link_btn.setText(self.tr("Create desktop link")) except PermissionError as e: logger.error(str(e)) diff --git a/rare/models/game.py b/rare/models/game.py index 8da0c23f..57ebbedd 100644 --- a/rare/models/game.py +++ b/rare/models/game.py @@ -14,8 +14,7 @@ from rare.lgndr.core import LegendaryCore from rare.models.install import InstallOptionsModel, UninstallOptionsModel from rare.shared.game_process import GameProcess from rare.shared.image_manager import ImageManager -from rare.utils.misc import get_rare_executable -from rare.utils.paths import data_dir +from rare.utils.paths import data_dir, get_rare_executable logger = getLogger("RareGame") diff --git a/rare/utils/misc.py b/rare/utils/misc.py index f5594010..bef7591e 100644 --- a/rare/utils/misc.py +++ b/rare/utils/misc.py @@ -1,7 +1,5 @@ import os import platform -import shlex -import sys from logging import getLogger from typing import List, Union, Type @@ -12,23 +10,19 @@ from PyQt5.QtCore import ( QObject, QRunnable, QSettings, - QStandardPaths, QFile, - QDir, Qt, + QDir, + Qt, ) -from PyQt5.QtGui import QPalette, QColor, QImage, QFontMetrics -from PyQt5.QtWidgets import qApp, QStyleFactory, QWidget, QLabel +from PyQt5.QtGui import QPalette, QColor, QFontMetrics +from PyQt5.QtWidgets import qApp, QStyleFactory, QLabel from PyQt5.sip import wrappertype from legendary.core import LegendaryCore from legendary.models.game import Game from requests.exceptions import HTTPError from rare.models.apiresults import ApiResults -from rare.utils.paths import image_dir, resources_path - -if platform.system() == "Windows": - # noinspection PyUnresolvedReferences - from win32com.client import Dispatch # pylint: disable=E0401 +from rare.utils.paths import resources_path logger = getLogger("Utils") settings = QSettings("Rare", "Rare") @@ -156,152 +150,6 @@ def get_size(b: Union[int, float]) -> str: b /= 1024 -def get_rare_executable() -> List[str]: - # lk: detect if nuitka - if "__compiled__" in globals(): - executable = [sys.executable] - elif platform.system() == "Linux" or platform.system() == "Darwin": - if p := os.environ.get("APPIMAGE"): - executable = [p] - else: - if sys.executable == os.path.abspath(sys.argv[0]): - executable = [sys.executable] - else: - executable = [sys.executable, os.path.abspath(sys.argv[0])] - elif platform.system() == "Windows": - executable = [sys.executable] - - if sys.executable != os.path.abspath(sys.argv[0]): - executable.append(os.path.abspath(sys.argv[0])) - - if executable[0].endswith("python.exe"): - # be sure to start consoleless then - executable[0] = executable[0].replace("python.exe", "pythonw.exe") - if executable[1].endswith("rare"): - executable[1] = executable[1] + ".exe" - else: - executable = [sys.executable] - - executable[0] = os.path.abspath(executable[0]) - return executable - - -def create_desktop_link(app_name=None, core: LegendaryCore = None, type_of_link="desktop", - for_rare: bool = False) -> bool: - if not for_rare: - igame = core.get_installed_game(app_name) - - icon = os.path.join(os.path.join(image_dir(), igame.app_name, "installed.png")) - icon = icon.replace(".png", "") - - if platform.system() == "Linux": - if type_of_link == "desktop": - path = QStandardPaths.writableLocation(QStandardPaths.DesktopLocation) - elif type_of_link == "start_menu": - path = QStandardPaths.writableLocation(QStandardPaths.ApplicationsLocation) - else: - return False - if not os.path.exists(path): - return False - executable = get_rare_executable() - executable = shlex.join(executable) - - if for_rare: - with open(os.path.join(path, "Rare.desktop"), "w") as desktop_file: - desktop_file.write( - "[Desktop Entry]\n" - f"Name=Rare\n" - f"Type=Application\n" - f"Categories=Game;\n" - f"Icon={os.path.join(resources_path, 'images', 'Rare.png')}\n" - f"Exec={executable}\n" - "Terminal=false\n" - "StartupWMClass=Rare\n" - ) - else: - with open(os.path.join(path, f"{igame.title}.desktop"), "w") as desktop_file: - desktop_file.write( - "[Desktop Entry]\n" - f"Name={igame.title}\n" - f"Type=Application\n" - f"Categories=Game;\n" - f"Icon={icon}.png\n" - f"Exec={executable} launch {app_name}\n" - "Terminal=false\n" - "StartupWMClass=Rare\n" - ) - os.chmod(os.path.join(path, f"{igame.title}.desktop"), 0o755) - - return True - - elif platform.system() == "Windows": - # Target of shortcut - if type_of_link == "desktop": - target_folder = QStandardPaths.writableLocation(QStandardPaths.DesktopLocation) - elif type_of_link == "start_menu": - target_folder = os.path.join( - QStandardPaths.writableLocation(QStandardPaths.ApplicationsLocation), - ".." - ) - else: - logger.warning("No valid type of link") - return False - if not os.path.exists(target_folder): - return False - - if for_rare: - linkName = "Rare.lnk" - else: - linkName = igame.title - # TODO: this conversion is not applied everywhere (see base_installed_widget), should it? - for c in r'<>?":|\/*': - linkName = linkName.replace(c, "") - - linkName = f"{linkName.strip()}.lnk" - - # Path to location of link file - pathLink = os.path.join(target_folder, linkName) - - # Add shortcut - shell = Dispatch("WScript.Shell") - shortcut = shell.CreateShortCut(pathLink) - - executable = get_rare_executable() - arguments = [] - - if len(executable) > 1: - arguments.extend(executable[1:]) - executable = executable[0] - - if not for_rare: - arguments.extend(["launch", app_name]) - - shortcut.Targetpath = executable - # Maybe there is a better solution, but windows does not accept single quotes (Windows is weird) - shortcut.Arguments = shlex.join(arguments).replace("'", '"') - if for_rare: - shortcut.WorkingDirectory = QStandardPaths.writableLocation(QStandardPaths.HomeLocation) - - # Icon - if for_rare: - icon_location = os.path.join(resources_path, "images", "Rare.ico") - else: - if not os.path.exists(f"{icon}.ico"): - img = QImage() - img.load(f"{icon}.png") - img.save(f"{icon}.ico") - logger.info("Created ico file") - icon_location = f"{icon}.ico" - shortcut.IconLocation = os.path.abspath(icon_location) - - shortcut.save() - return True - - # mac OS is based on Darwin - elif platform.system() == "Darwin": - return False - - class CloudWorker(QRunnable): class Signals(QObject): # List[SaveGameFile] diff --git a/rare/utils/paths.py b/rare/utils/paths.py index ba342ec7..dab3d912 100644 --- a/rare/utils/paths.py +++ b/rare/utils/paths.py @@ -1,8 +1,20 @@ import os +import platform +import shlex import shutil +import sys +from logging import getLogger from pathlib import Path +from typing import List from PyQt5.QtCore import QStandardPaths +from PyQt5.QtGui import QImage + +if platform.system() == "Windows": + # noinspection PyUnresolvedReferences + from win32com.client import Dispatch # pylint: disable=E0401 + +logger = getLogger("Paths") resources_path = Path(__file__).absolute().parent.parent.joinpath("resources") @@ -15,7 +27,7 @@ for old_dir in [ ]: if old_dir.exists(): # lk: case-sensitive matching on Winblows - if old_dir.stem in os.listdir(old_dir.parent): + if old_dir.stem in old_dir.parent.iterdir(): shutil.rmtree(old_dir, ignore_errors=True) @@ -49,3 +61,180 @@ def create_dirs() -> None: for path in (data_dir(), cache_dir(), image_dir(), log_dir(), tmp_dir()): if not path.exists(): path.mkdir(parents=True) + + +def home_dir() -> Path: + return Path(QStandardPaths.writableLocation(QStandardPaths.HomeLocation)) + + +def desktop_dir() -> Path: + return Path(QStandardPaths.writableLocation(QStandardPaths.DesktopLocation)) + + +def applications_dir() -> Path: + return Path(QStandardPaths.writableLocation(QStandardPaths.ApplicationsLocation)) + + +__link_suffix = { + "Windows": ".lnk", + "Linux": ".desktop", +} + + +def desktop_links_supported() -> bool: + return platform.system() in __link_suffix.keys() + + +__link_type = { + "desktop": desktop_dir(), + # lk: for some undocumented reason, on Windows we used the parent directory + # lk: for start menu items. Mirror it here for backwards compatibility + "start_menu": applications_dir().parent if platform.system() == "Windows" else applications_dir(), +} + + +def desktop_link_path(link_name: str, link_type: str) -> Path: + """ + Creates the path of a shortcut link + + :param link_name: + Name of the shortcut file + :param link_type: + "desktop" or "start_menu" + :return Path: + shortcut path + """ + return __link_type[link_type].joinpath(f"{link_name}{__link_suffix[platform.system()]}") + + +def get_rare_executable() -> List[str]: + # lk: detect if nuitka + if "__compiled__" in globals(): + executable = [sys.executable] + elif platform.system() == "Linux" or platform.system() == "Darwin": + if p := os.environ.get("APPIMAGE"): + executable = [p] + else: + if sys.executable == os.path.abspath(sys.argv[0]): + executable = [sys.executable] + else: + executable = [sys.executable, os.path.abspath(sys.argv[0])] + elif platform.system() == "Windows": + executable = [sys.executable] + + if sys.executable != os.path.abspath(sys.argv[0]): + executable.append(os.path.abspath(sys.argv[0])) + + if executable[0].endswith("python.exe"): + # be sure to start consoleless then + executable[0] = executable[0].replace("python.exe", "pythonw.exe") + if executable[1].endswith("rare"): + executable[1] = executable[1] + ".exe" + else: + executable = [sys.executable] + + executable[0] = os.path.abspath(executable[0]) + return executable + + +def create_desktop_link(app_name: str, app_title: str = "", link_name: str = "", link_type="desktop") -> bool: + """ + Creates a desktop or start menu shortcut link + + :param app_name: + app_name or "rare_shortcut" for Rare itself + :param app_title: + the title shown in the shortcut + (overrides to "Rare" for "rare_shortcut") + :param link_name: + the sanitized filename of the shortcut (use the folder_name attribute of RareGame) + (overrides to "Rare" for "rare_shortcut") + :param link_type: + "desktop" or "start_menu" + :return bool: + True if successful else False + """ + # macOS is based on Darwin + if not desktop_links_supported(): + logger.error(f"Shortcut creation is not available on {platform.system()}") + return False + + if link_type not in ["desktop", "start_menu"]: + logger.error(f"Invalid link type {link_type}") + return False + + for_rare = app_name == "rare_shortcut" + + if for_rare: + icon_path = resources_path.joinpath("images", "Rare.png") + if platform.system() == "Windows": + icon_path = resources_path.joinpath("images", "Rare.ico") + app_title = "Rare" + link_name = "Rare" + else: + icon_path = image_dir().joinpath(app_name, "installed.png") + if platform.system() == "Windows": + icon_path = image_dir().joinpath(app_name, "installed.ico") + if not icon_path.exists(): + img = QImage() + img.load(str(icon_path.with_suffix(".png"))) + img.save(str(icon_path)) + logger.info("Created ico file") + if not app_title or not link_name: + logger.error("Missing app_title or link_name") + return False + + shortcut_path = desktop_link_path(link_name, link_type) + if not shortcut_path.parent.exists(): + logger.error(f"Parent directory {shortcut_path.parent} does not exist") + return False + else: + logger.info(f"Creating shortcut for {app_title} at {shortcut_path}") + + if platform.system() == "Linux": + executable = get_rare_executable() + executable = shlex.join(executable) + if not for_rare: + executable = f"{executable} launch {app_name}" + + with shortcut_path.open(mode="w") as desktop_file: + desktop_file.write( + "[Desktop Entry]\n" + f"Name={app_title}\n" + "Type=Application\n" + "Categories=Game;\n" + f"Icon={icon_path}\n" + f"Exec={executable}\n" + "Terminal=false\n" + "StartupWMClass=Rare\n" + ) + # shortcut_path.chmod(0o755) + return True + + elif platform.system() == "Windows": + # Add shortcut + shell = Dispatch("WScript.Shell") + shortcut = shell.CreateShortCut(str(shortcut_path)) + + executable = get_rare_executable() + arguments = [] + + if len(executable) > 1: + arguments.extend(executable[1:]) + executable = executable[0] + + if not for_rare: + arguments.extend(["launch", app_name]) + shortcut.Targetpath = executable + # Maybe there is a better solution, but windows does not accept single quotes (Windows is weird) + shortcut.Arguments = shlex.join(arguments).replace("'", '"') + + shortcut.Description = app_title + if for_rare: + shortcut.WorkingDirectory = str(home_dir()) + + # Icon + shortcut.IconLocation = icon_path.absolute() + + shortcut.save() + return True