From 7ee008e150bc5bcf76082d726f719ee0fdfda982 Mon Sep 17 00:00:00 2001 From: SulfurNitride Date: Wed, 11 Feb 2026 02:37:39 -0600 Subject: Fluorine Manager: full Linux port of Mod Organizer 2 Complete native Linux port with FUSE-based virtual filesystem, Proton/umu-run integration, and Flatpak packaging. Key features: - FUSE VFS replacing Windows USVFS (in-process + standalone helper for Flatpak) - Proton/GE-Proton/umu-run launcher with env var forwarding - Flatpak support (sandbox-aware VFS, NXM handler, umu-run) - Wine prefix management UI - Case-insensitive path resolution for Linux filesystems - QSettings-safe INI handling (avoids Bethesda INI corruption) - Portable instance support with auto-generated launcher scripts Co-Authored-By: Claude Opus 4.6 --- .../basic_features/basic_save_game_info.py | 226 +++++++++++++++++++++ 1 file changed, 226 insertions(+) create mode 100644 libs/basic_games/basic_features/basic_save_game_info.py (limited to 'libs/basic_games/basic_features/basic_save_game_info.py') diff --git a/libs/basic_games/basic_features/basic_save_game_info.py b/libs/basic_games/basic_features/basic_save_game_info.py new file mode 100644 index 0000000..436e724 --- /dev/null +++ b/libs/basic_games/basic_features/basic_save_game_info.py @@ -0,0 +1,226 @@ +# -*- encoding: utf-8 -*- + +import sys +from collections.abc import Mapping +from datetime import datetime +from pathlib import Path +from typing import Any, Callable, Self, Sequence + +from PyQt6.QtCore import QDateTime, QLocale, Qt +from PyQt6.QtGui import QImage, QPixmap +from PyQt6.QtWidgets import QFormLayout, QLabel, QSizePolicy, QVBoxLayout, QWidget + +import mobase + + +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): + def __init__(self, filepath: Path): + super().__init__() + self._filepath = filepath + + def getFilepath(self) -> str: + return self._filepath.as_posix() + + def getName(self) -> str: + return self._filepath.name + + def getCreationTime(self): + return QDateTime.fromSecsSinceEpoch(int(self._filepath.stat().st_mtime)) + + def getSaveGroupIdentifier(self) -> str: + return "" + + 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] | 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) + + def _no_preview(p: Path) -> None: + return None + + self._get_preview = get_preview or _no_preview + 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() + layout.addWidget(self._label) + self.setLayout(layout) + + self.setWindowFlags( + Qt.WindowType.ToolTip | Qt.WindowType.BypassGraphicsProxyWidget + ) + + def setSave(self, save: mobase.ISaveGame): + 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 metadata or pixmap: + self.adjustSize() + self.show() + + 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 + + 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_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 {} + + def getSaveGameWidget( + self, parent: QWidget | None = None + ) -> mobase.ISaveGameInfoWidget | None: + if self._get_widget: + return self._get_widget(parent) + else: + return None -- cgit v1.3.1