From 44645a54f777f375dae7a53876a3d83ff6bf10b3 Mon Sep 17 00:00:00 2001 From: Zash Date: Sun, 8 Oct 2023 18:24:33 +0200 Subject: [PATCH] Add metadata to BasicGameSaveGameInfo(Widget) (#109) * Fix subnautica preview file path * Add BasicGameSaveGameInfo with "File Date" to all BasicGames * Add max_width option to BasicGameSaveGameInfo(Widget) * Refactor Blade & Sorcery with get_metadata * Refactor & cleanup Black & White SaveGameInfo --- README.md | 8 +- basic_features/basic_save_game_info.py | 195 +++++++++++++++++++------ basic_game.py | 6 +- games/game_blackandwhite2.py | 40 +---- games/game_bladeandsorcery.py | 163 ++------------------- games/game_subnautica.py | 2 +- 6 files changed, 178 insertions(+), 236 deletions(-) diff --git a/README.md b/README.md index ee3929a..408260a 100644 --- a/README.md +++ b/README.md @@ -176,9 +176,11 @@ The meta-plugin provides some useful extra feature: `GameOriginManifestIds`, `GameEpicId` or `GameEaDesktopId`), the game will be listed in the list of available games when creating a new MO2 instance (if the game is installed via Steam, GOG, Origin, Epic Games / Legendary or EA Desktop). -2. **Basic save game preview:** If you use the Python version, and if you can easily obtain a picture (file) - for any saves, you can provide basic save-game preview by using the `BasicGameSaveGameInfo`. - See [games/game_witcher3.py](games/game_witcher3.py) for more details. +2. **Basic save game preview / metadata** (Python): If you can easily obtain a picture + (file) and/or metadata (like from json) for any saves, you can provide basic save-game + preview by using the `BasicGameSaveGameInfo`. See + [games/game_witcher3.py](games/game_witcher3.py) and + [games/game_bladeandsorcery.py](games/game_bladeandsorcery.py) for more details. 3. **Basic local save games** (Python): profile specific save games, as in [games/game_valheim.py](games/game_valheim.py). 4. **Basic mod data checker** (Python): Check and fix different mod archive layouts for an automatic installation with the proper diff --git a/basic_features/basic_save_game_info.py b/basic_features/basic_save_game_info.py index 32ce2c4..9adf2f9 100644 --- a/basic_features/basic_save_game_info.py +++ b/basic_features/basic_save_game_info.py @@ -1,13 +1,31 @@ # -*- encoding: utf-8 -*- import sys +from collections.abc import Mapping +from datetime import datetime from pathlib import Path -from typing import Callable, Sequence +from typing import Any, Callable, Self, Sequence import mobase -from PyQt6.QtCore import QDateTime, Qt +from PyQt6.QtCore import QDateTime, QLocale, Qt from PyQt6.QtGui import QImage, QPixmap -from PyQt6.QtWidgets import QLabel, QVBoxLayout, QWidget +from PyQt6.QtWidgets import QFormLayout, QLabel, QSizePolicy, QVBoxLayout, QWidget + + +def format_date(date_time: QDateTime | datetime | str, format_str: str | None = None): + """Default format for date and time in the `BasicGameSaveGameInfoWidget`. + + Args: + date_time: either a `QDateTime`/`datetime` or a string together with + a `format_str`. + format_str (optional): date/time format string (see `QDateTime.fromString`). + + Returns: + Date and time in short locale format. + """ + if isinstance(date_time, str): + date_time = QDateTime.fromString(date_time, format_str) + return QLocale.system().toString(date_time, QLocale.FormatType.ShortFormat) class BasicGameSaveGame(mobase.ISaveGame): @@ -31,74 +49,162 @@ def allFiles(self) -> list[str]: return [self.getFilepath()] +def get_filedate_metadata(p: Path, save: mobase.ISaveGame) -> Mapping[str, str]: + """Returns saves file date as the metadata for `BasicGameSaveGameInfoWidget`.""" + return {"File Date:": format_date(save.getCreationTime())} + + class BasicGameSaveGameInfoWidget(mobase.ISaveGameInfoWidget): + """Save game info widget to display metadata and a preview.""" + def __init__( self, parent: QWidget | None, - get_preview: Callable[ - [Path], QPixmap | QImage | Path | str | None - ] = lambda p: None, + get_preview: Callable[[Path], QPixmap | QImage | Path | str | None] + | None = lambda p: None, + get_metadata: Callable[[Path, mobase.ISaveGame], Mapping[str, Any] | None] + | None = get_filedate_metadata, + max_width: int = 320, ): + """ + Args: + parent: parent widget + get_preview (optional): `callback(savegame_path)` returning the + saves preview image or the path to it. + get_metadata (optional): `callback(savegame_path, ISaveGame)` returning + the saves metadata. By default the saves file date is shown. + max_width (optional): The maximum widget and (scaled) preview width. + Defaults to 320. + """ super().__init__(parent) - self._get_preview = get_preview + self._get_preview = get_preview or (lambda p: None) + self._get_metadata = get_metadata or get_filedate_metadata + self._max_width = max_width or 320 layout = QVBoxLayout() + + # Metadata form + self._metadata_widget = QWidget() + self._metadata_widget.setMaximumWidth(self._max_width) + self._metadata_layout = form_layout = QFormLayout(self._metadata_widget) + form_layout.setContentsMargins(0, 0, 0, 0) + form_layout.setVerticalSpacing(2) + layout.addWidget(self._metadata_widget) + self._metadata_widget.hide() # Backwards compatibility (no metadata) + + # Preview (pixmap) self._label = QLabel() - palette = self._label.palette() - palette.setColor(self._label.foregroundRole(), Qt.GlobalColor.white) - self._label.setPalette(palette) layout.addWidget(self._label) self.setLayout(layout) - palette = self.palette() - palette.setColor(self.backgroundRole(), Qt.GlobalColor.black) - self.setAutoFillBackground(True) - self.setPalette(palette) - self.setWindowFlags( Qt.WindowType.ToolTip | Qt.WindowType.BypassGraphicsProxyWidget ) def setSave(self, save: mobase.ISaveGame): - # Resize the label to (0, 0) to hide it: - self.resize(0, 0) - - # Retrieve the pixmap: - value = self._get_preview(Path(save.getFilepath())) + save_path = Path(save.getFilepath()) + + # Clear previous + self.hide() + self._label.clear() + while self._metadata_layout.count(): + layoutItem = self._metadata_layout.takeAt(0) + if layoutItem is not None and (w := layoutItem.widget()): + w.deleteLater() + + # Retrieve the pixmap and metadata: + preview = self._get_preview(save_path) + pixmap = None + + # Set the preview pixmap if the preview file exits + if preview is not None: + if isinstance(preview, str): + preview = Path(preview) + if isinstance(preview, Path): + if preview.exists(): + pixmap = QPixmap(str(preview)) + else: + print( + f"Failed to retrieve the preview, file not found: {preview}", + file=sys.stderr, + ) + elif isinstance(preview, QImage): + pixmap = QPixmap.fromImage(preview) + else: + pixmap = preview + if pixmap and not pixmap.isNull(): + # Scale the pixmap and show it: + pixmap = pixmap.scaledToWidth(self._max_width) + self._label.setPixmap(pixmap) + self._label.show() + else: + self._label.hide() + pixmap = None + + # Add metadata, file date by default. + metadata = self._get_metadata(save_path, save) + if metadata: + for key, value in metadata.items(): + self._metadata_layout.addRow(*self._new_form_row(key, str(value))) + self._metadata_widget.show() + self._metadata_widget.setLayout(self._metadata_layout) + self._metadata_widget.adjustSize() + else: + self._metadata_widget.hide() - if value is None: - return + if metadata or pixmap: + self.adjustSize() + self.show() - if isinstance(value, Path): - pixmap = QPixmap(str(value)) - elif isinstance(value, str): - pixmap = QPixmap(value) - elif isinstance(value, QImage): - pixmap = QPixmap.fromImage(value) - else: - print( - "Failed to retrieve the preview, bad return type: {}.".format( - type(value) - ), - file=sys.stderr, - ) - return + def _new_form_row(self, label: str = "", field: str = ""): + qLabel = QLabel(text=label) + qLabel.setAlignment(Qt.AlignmentFlag.AlignTop) + qLabel.setStyleSheet("font: italic") + qLabel.setSizePolicy(QSizePolicy.Policy.Minimum, QSizePolicy.Policy.Minimum) + qField = QLabel(text=field) + qField.setWordWrap(True) + qField.setAlignment(Qt.AlignmentFlag.AlignTop) + qField.setStyleSheet("font: bold") + qField.setSizePolicy(QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Preferred) + return qLabel, qField - # Scale the pixmap and show it: - pixmap = pixmap.scaledToWidth(320) - self._label.setPixmap(pixmap) - self.resize(pixmap.width(), pixmap.height()) + def set_maximum_width(self, width: int): + self._max_width = width + self._metadata_widget.setMaximumWidth(width) class BasicGameSaveGameInfo(mobase.SaveGameInfo): + _get_widget: Callable[[QWidget | None], mobase.ISaveGameInfoWidget | None] | None + def __init__( self, get_preview: Callable[[Path], QPixmap | QImage | Path | str | None] | None = None, + get_metadata: Callable[[Path, mobase.ISaveGame], Mapping[str, Any] | None] + | None = None, + max_width: int = 0, ): + """Args from: `BasicGameSaveGameInfoWidget`.""" super().__init__() - self._get_preview = get_preview + self._get_widget = lambda parent: BasicGameSaveGameInfoWidget( + parent, get_preview, get_metadata, max_width + ) + + @classmethod + def with_widget( + cls, + widget: type[mobase.ISaveGameInfoWidget] | None, + ) -> Self: + """ + + Args: + widget: a custom `ISaveGameInfoWidget` instead of the default + `BasicGameSaveGameInfoWidget`. + """ + self = cls() + self._get_widget = lambda parent: widget(parent) if widget else None + return self def getMissingAssets(self, save: mobase.ISaveGame) -> dict[str, Sequence[str]]: return {} @@ -106,6 +212,7 @@ def getMissingAssets(self, save: mobase.ISaveGame) -> dict[str, Sequence[str]]: def getSaveGameWidget( self, parent: QWidget | None = None ) -> mobase.ISaveGameInfoWidget | None: - if self._get_preview is not None: - return BasicGameSaveGameInfoWidget(parent, self._get_preview) - return None + if self._get_widget: + return self._get_widget(parent) + else: + return None diff --git a/basic_game.py b/basic_game.py index c60d3e3..9149c78 100644 --- a/basic_game.py +++ b/basic_game.py @@ -9,7 +9,10 @@ from PyQt6.QtCore import QDir, QFileInfo, QStandardPaths from PyQt6.QtGui import QIcon -from .basic_features.basic_save_game_info import BasicGameSaveGame +from .basic_features.basic_save_game_info import ( + BasicGameSaveGame, + BasicGameSaveGameInfo, +) def replace_variables(value: str, game: BasicGame) -> str: @@ -427,6 +430,7 @@ def is_eadesktop(self) -> bool: def init(self, organizer: mobase.IOrganizer) -> bool: self._organizer = organizer + self._featureMap[mobase.SaveGameInfo] = BasicGameSaveGameInfo() if self._mappings.originWatcherExecutables.get(): from .origin_utils import OriginWatcher diff --git a/games/game_blackandwhite2.py b/games/game_blackandwhite2.py index a5ff398..c81df30 100644 --- a/games/game_blackandwhite2.py +++ b/games/game_blackandwhite2.py @@ -1,7 +1,6 @@ import datetime import os import struct -import sys import time from pathlib import Path from typing import BinaryIO @@ -9,13 +8,11 @@ import mobase from PyQt6.QtCore import QDateTime, QDir, QFile, QFileInfo, Qt from PyQt6.QtGui import QPainter, QPixmap -from PyQt6.QtWidgets import QWidget from ..basic_features import BasicLocalSavegames from ..basic_features.basic_save_game_info import ( BasicGameSaveGame, BasicGameSaveGameInfo, - BasicGameSaveGameInfoWidget, ) from ..basic_game import BasicGame @@ -281,41 +278,6 @@ def _getPreview(savepath: Path): return pixmap.copy(0, 0, width, height) -class BlackAndWhite2SaveGameInfoWidget(BasicGameSaveGameInfoWidget): - def setSave(self, save: mobase.ISaveGame): - # Resize the label to (0, 0) to hide it: - self.resize(0, 0) - - # Retrieve the pixmap: - value = self._get_preview(Path(save.getFilepath())) - - if value is None: - return - - elif isinstance(value, QPixmap): - pixmap = value - else: - print( - "Failed to retrieve the preview, bad return type: {}.".format( - type(value) - ), - file=sys.stderr, - ) - return - - # Scale the pixmap and show it: - # pixmap = pixmap.scaledToWidth(pixmap.width()) - self._label.setPixmap(pixmap) - self.resize(pixmap.width(), pixmap.height()) - - -class BlackAndWhite2SaveGameInfo(BasicGameSaveGameInfo): - def getSaveGameWidget(self, parent: QWidget | None = None): - if self._get_preview is not None: - return BasicGameSaveGameInfoWidget(parent, self._get_preview) - return None - - PSTART_MENU = ( str(os.getenv("ProgramData")) + "\\Microsoft\\Windows\\Start Menu\\Programs" ) @@ -350,7 +312,7 @@ def init(self, organizer: mobase.IOrganizer) -> bool: self._featureMap[mobase.LocalSavegames] = BasicLocalSavegames( self.savesDirectory() ) - self._featureMap[mobase.SaveGameInfo] = BlackAndWhite2SaveGameInfo(_getPreview) + self._featureMap[mobase.SaveGameInfo] = BasicGameSaveGameInfo(_getPreview) return True def detectGame(self): diff --git a/games/game_bladeandsorcery.py b/games/game_bladeandsorcery.py index 60246f2..6e0569c 100644 --- a/games/game_bladeandsorcery.py +++ b/games/game_bladeandsorcery.py @@ -1,21 +1,14 @@ import json +from collections.abc import Mapping from pathlib import Path import mobase -from PyQt6.QtCore import QDateTime, QDir, QLocale, Qt -from PyQt6.QtGui import QFont -from PyQt6.QtWidgets import ( - QFormLayout, - QLabel, - QSizePolicy, - QStyle, - QVBoxLayout, - QWidget, -) +from PyQt6.QtCore import QDateTime, QDir from ..basic_features.basic_save_game_info import ( BasicGameSaveGame, BasicGameSaveGameInfo, + format_date, ) from ..basic_game import BasicGame @@ -59,143 +52,15 @@ def getGameMode(self) -> str: return self._gameMode -class BaSSaveGameInfoWidget(mobase.ISaveGameInfoWidget): - def __init__(self, parent: QWidget | None = None): - super().__init__(parent) - self.resize(400, 125) - sizePolicy = QSizePolicy( - QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Minimum - ) - sizePolicy.setHorizontalStretch(0) - sizePolicy.setVerticalStretch(0) - sizePolicy.setHeightForWidth(self.sizePolicy().hasHeightForWidth()) - self.setSizePolicy(sizePolicy) - self._verticalLayout = QVBoxLayout() - self._verticalLayout.setObjectName("verticalLayout") - self._formLayout = QFormLayout() - self._formLayout.setObjectName("formLayout") - self._formLayout.setFieldGrowthPolicy( - QFormLayout.FieldGrowthPolicy.AllNonFixedFieldsGrow - ) - - self._label = QLabel() - self._label.setObjectName("label") - font = QFont() - font.setItalic(True) - self._label.setFont(font) - self._label.setText("Character") - - self._formLayout.setWidget(0, QFormLayout.ItemRole.LabelRole, self._label) - - self._label_2 = QLabel() - self._label_2.setObjectName("label_2") - self._label_2.setFont(font) - self._label_2.setText("Game Mode") - - self._formLayout.setWidget(1, QFormLayout.ItemRole.LabelRole, self._label_2) - - self._label_3 = QLabel() - self._label_3.setObjectName("label_4") - self._label_3.setFont(font) - self._label_3.setText("Created At") - - self._formLayout.setWidget(2, QFormLayout.ItemRole.LabelRole, self._label_3) - - self._label_4 = QLabel() - self._label_4.setObjectName("label_4") - self._label_4.setFont(font) - self._label_4.setText("Last Saved") - - self._formLayout.setWidget(3, QFormLayout.ItemRole.LabelRole, self._label_4) - - self._label_5 = QLabel() - self._label_5.setObjectName("label_3") - self._label_5.setFont(font) - self._label_5.setText("Session Duration") - - self._formLayout.setWidget(4, QFormLayout.ItemRole.LabelRole, self._label_5) - - font1 = QFont() - font1.setBold(True) - - self._characterLabel = QLabel() - self._characterLabel.setObjectName("characterLabel") - self._characterLabel.setFont(font1) - self._characterLabel.setText("") - - self._formLayout.setWidget( - 0, QFormLayout.ItemRole.FieldRole, self._characterLabel - ) - - self._gameModeLabel = QLabel() - self._gameModeLabel.setObjectName("gameModeLabel") - self._gameModeLabel.setFont(font1) - self._gameModeLabel.setText("") - - self._formLayout.setWidget( - 1, QFormLayout.ItemRole.FieldRole, self._gameModeLabel - ) - - self._dateLabel = QLabel() - self._dateLabel.setObjectName("dateLabel") - self._dateLabel.setFont(font1) - self._dateLabel.setText("") - - self._formLayout.setWidget(2, QFormLayout.ItemRole.FieldRole, self._dateLabel) - - self._sessionLabel = QLabel() - self._sessionLabel.setObjectName("sessionLabel") - self._sessionLabel.setFont(font1) - self._sessionLabel.setText("") - - self._formLayout.setWidget( - 3, QFormLayout.ItemRole.FieldRole, self._sessionLabel - ) - - self._elapsedTimeLabel = QLabel() - self._elapsedTimeLabel.setObjectName("elapsedTimeLabel") - self._elapsedTimeLabel.setFont(font1) - self._elapsedTimeLabel.setText("") - - self._formLayout.setWidget( - 4, QFormLayout.ItemRole.FieldRole, self._elapsedTimeLabel - ) - - self._verticalLayout.addLayout(self._formLayout) - - self.setLayout(self._verticalLayout) - self.setWindowFlags( - Qt.WindowType.ToolTip | Qt.WindowType.BypassGraphicsProxyWidget - ) - style = self.style() - if style is not None: - self.setWindowOpacity( - style.styleHint(QStyle.StyleHint.SH_ToolTipLabel_Opacity) / 255.0 - ) - - def setSave(self, save: mobase.ISaveGame): - assert isinstance(save, BaSSaveGame) - self._characterLabel.setText(save.getPlayerSlug()) - self._gameModeLabel.setText(save.getGameMode()) - t = save.getCreationTime().toLocalTime() - self._dateLabel.setText( - QLocale.system().toString(t.date(), QLocale.FormatType.ShortFormat) - + " " - + QLocale.system().toString(t.time()) - ) - s = save.getModifiedTime().toLocalTime() - self._sessionLabel.setText( - QLocale.system().toString(s.date(), QLocale.FormatType.ShortFormat) - + " " - + QLocale.system().toString(s.time()) - ) - self._elapsedTimeLabel.setText(save.getElapsed()) - self.resize(0, 125) - - -class BaSSaveGameInfo(BasicGameSaveGameInfo): - def getSaveGameWidget(self, parent: QWidget | None = None): - return BaSSaveGameInfoWidget(parent) +def bas_parse_metadata(p: Path, save: mobase.ISaveGame) -> Mapping[str, str]: + assert isinstance(save, BaSSaveGame) + return { + "Character": save.getPlayerSlug(), + "Game Mode": save.getGameMode(), + "Created At": format_date(save.getCreationTime()), + "Last Saved": format_date(save.getModifiedTime()), + "Session Duration": save.getElapsed(), + } class BaSGame(BasicGame): @@ -218,7 +83,9 @@ class BaSGame(BasicGame): def init(self, organizer: mobase.IOrganizer) -> bool: BasicGame.init(self, organizer) - self._featureMap[mobase.SaveGameInfo] = BaSSaveGameInfo() + self._featureMap[mobase.SaveGameInfo] = BasicGameSaveGameInfo( + get_metadata=bas_parse_metadata, max_width=400 + ) return True def listSaves(self, folder: QDir) -> list[mobase.ISaveGame]: diff --git a/games/game_subnautica.py b/games/game_subnautica.py index b2c64c3..ac915b7 100644 --- a/games/game_subnautica.py +++ b/games/game_subnautica.py @@ -83,7 +83,7 @@ def init(self, organizer: mobase.IOrganizer) -> bool: super().init(organizer) self._featureMap[mobase.ModDataChecker] = SubnauticaModDataChecker() self._featureMap[mobase.SaveGameInfo] = BasicGameSaveGameInfo( - lambda s: os.path.join(s, "screenshot.jpg") + lambda s: Path(s or "", "screenshot.jpg") ) return True