diff --git a/flake.nix b/flake.nix index 8424972..64ebfc9 100644 --- a/flake.nix +++ b/flake.nix @@ -3,36 +3,33 @@ inputs.nixpkgs.url = "nixpkgs/nixos-unstable"; - outputs = {nixpkgs, ...}: let - pkgs = nixpkgs.legacyPackages.x86_64-linux; - lib = nixpkgs.lib; - in { - devShells.x86_64-linux.default = pkgs.mkShellNoCC { - name = "leek-devshell"; - packages = with pkgs; [ - ruff - ( - python3.withPackages ( - ps: - with ps; [ - # Dev dependencies - python-lsp-server - pylsp-mypy - mypy - setuptools-scm - - #App dependencies - pyside6 - tomlkit - vdf - ] + outputs = { nixpkgs, ... }: + let + pkgs = nixpkgs.legacyPackages.x86_64-linux; + lib = nixpkgs.lib; + in + { + devShells.x86_64-linux.default = pkgs.mkShellNoCC { + name = "leek-devshell"; + packages = with pkgs; [ + ruff + ( + python3.withPackages (ps: with ps;[ + python-lsp-server + pylsp-mypy + mypy + setuptools-scm + pyside6 + tomlkit + ] + ) ) - ) - ]; + ]; + }; + packages.x86_64-linux = rec { + default = leek; + leek = pkgs.callPackage ./package.nix { }; + }; }; - packages.x86_64-linux = rec { - default = leek; - leek = pkgs.callPackage ./package.nix {}; - }; - }; } + diff --git a/package.nix b/package.nix index 48f4f64..febd6e7 100644 --- a/package.nix +++ b/package.nix @@ -1,11 +1,10 @@ { kdePackages, python3Packages, - qt6, -}: -python3Packages.buildPythonApplication rec { + qt6 +}: python3Packages.buildPythonApplication rec { pname = "leek"; - version = "0.3"; + version = "0.1"; pyproject = true; src = ./.; @@ -15,10 +14,10 @@ python3Packages.buildPythonApplication rec { setuptools-scm ]; + dependencies = with python3Packages; [ pyside6 tomlkit - vdf ]; nativeBuildInputs = [ @@ -27,7 +26,7 @@ python3Packages.buildPythonApplication rec { propagatedBuildInputs = [ kdePackages.kirigami - ]; + ]; makeWrapperArgs = [ "\${qtWrapperArgs[@]}" diff --git a/pyproject.toml b/pyproject.toml index 1ccbed4..a7857ca 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -17,9 +17,8 @@ classifiers = [ dependencies = [ "pyside6", "tomlkit", - "vdf", ] -requires-python = ">=3.12" +requires-python = ">=3.6" [project.readme] file = "README.md" @@ -48,8 +47,7 @@ mypy_path = "$MYPY_CONFIG_FILE_DIR/src" module = [ "PySide6.QtGui", "PySide6.QtCore", - "PySide6.QtQml", - "vdf" + "PySide6.QtQml" ] ignore_missing_imports = true diff --git a/src/leek/leek_app.py b/src/leek/leek_app.py index f85592e..5a93db9 100644 --- a/src/leek/leek_app.py +++ b/src/leek/leek_app.py @@ -3,16 +3,15 @@ import os import sys import signal -from PySide6.QtWidgets import QApplication +from PySide6.QtGui import QGuiApplication from PySide6.QtCore import QUrl from PySide6.QtQml import QQmlApplicationEngine from leek.mod_list import QAbstractListModel # noqa: F401, needs to be imported for QML -from leek.qmod_installer import QModInstaller # noqa: F401, needs to be imported for QML def main(): """Initializes and manages the application execution""" - app: QApplication = QApplication(sys.argv) + app: QGuiApplication = QGuiApplication(sys.argv) app.setDesktopFileName("xyz.toast003.leek") app.setApplicationName("Leek") engine = QQmlApplicationEngine() diff --git a/src/leek/mod_installer.py b/src/leek/mod_installer.py deleted file mode 100644 index 485d93f..0000000 --- a/src/leek/mod_installer.py +++ /dev/null @@ -1,118 +0,0 @@ -from pathlib import Path -from zipfile import Path as ZipPath -from zipfile import ZipFile, is_zipfile -from leek.utils import GameFinder - - -class ModInstaller: - __archive_path: Path - __archive_format: str - __is_flat: bool - __is_installed: bool - - def __init__(self, mod_path: Path) -> None: - self.__archive_path = mod_path - self.__is_installed = False - if is_zipfile(mod_path): - self.__archive_format = "zip" - archive: ZipFile = ZipFile(mod_path) - path: ZipPath = ZipPath(archive) - - # Make sure archive contains a mod - things_in_root: list[ZipPath] = list(path.iterdir()) - config_toml: ZipPath - match len(things_in_root): - case 0: - raise EmptyArchiveError - case 1: - config_toml = things_in_root[0] / "config.toml" - if config_toml.exists() and config_toml.is_file(): - # Mod can be extracted as is - self.__is_flat = False - self.__check_if_already_installed() - return - case _: - config_toml = path / "config.toml" - if config_toml.exists() and config_toml.is_file(): - # Mod needs to be extracted in a folder - self.__is_flat = True - self.__check_if_already_installed() - return - - # If we ever get here there's either no mod on the archive, or we failed to find it - raise NoModExceptionError(str(mod_path)) - - else: - raise UnsupportedArchiveTypeError(str(mod_path)) - - @property - def is_installed(self) -> bool: - return self.__is_installed - - def install(self) -> None: - """ - Install the mod to Project Diva's mod folder - """ - if self.__is_installed: - return - - game_path: Path = GameFinder.find() - if self.__is_flat: - mod_folder_name: str = self.__get_mod_folder_name() - ZipFile(self.__archive_path).extractall( - path=str(game_path / "mods" / mod_folder_name) - ) - else: - ZipFile(self.__archive_path).extractall(path=str(game_path / "mods")) - - self.__is_installed = True - - def __get_mod_folder_name(self) -> str: - mod_folder_name: str - if self.__is_flat: - mod_folder_name = self.__archive_path.stem - else: - path: ZipPath = ZipPath(ZipFile(self.__archive_path)) - things_in_root: list[ZipPath] = list(path.iterdir()) - mod_folder_name = things_in_root[0].stem - - return mod_folder_name - - def __check_if_already_installed(self) -> None: - installed_mod_path: Path = ( - GameFinder.find() / "mods" / self.__get_mod_folder_name() - ) - if installed_mod_path.exists(): - self.__is_installed = True - - -class UnsupportedArchiveTypeError(Exception): - """ - Rased when ModInstaller runs into an archive that it can't extract - """ - - __message: str - - def __init__(self, archive_path: str) -> None: - self.__message = f"Don't know how to unpack archive at {archive_path}" - - def __str__(self) -> str: - return f"{self.__message}" - - -class EmptyArchiveError(Exception): - pass - - -class NoModExceptionError(Exception): - """ - Raised if the archive does not have a mod inside - """ - - __message: str - - def __init__(self, archive_path: str) -> None: - self.__message = f"Archive at {archive_path} does not have a mod" - - def __str__(self) -> str: - return f"{self.__message}" diff --git a/src/leek/mod_list.py b/src/leek/mod_list.py index 3c13d58..5357bba 100644 --- a/src/leek/mod_list.py +++ b/src/leek/mod_list.py @@ -1,26 +1,28 @@ -from PySide6.QtQml import QmlElement, QmlSingleton +from PySide6.QtQml import QmlElement from PySide6.QtCore import QAbstractListModel, QModelIndex from leek.mod import InvalidModError from leek.qmod import QMod from pathlib import Path -from leek.utils import GameFinder QML_IMPORT_NAME = "Leek" QML_IMPORT_MAJOR_VERSION = 1 +# TODO: Don't harcode the mods path +GAME_PATH = Path( + "/home/toast/.local/share/Steam/steamapps/common/Hatsune Miku Project DIVA Mega Mix Plus/" +) +MOD_PATH = Path(GAME_PATH, "mods") + # Qt follows C++ naming conventions # ruff: noqa: N802 @QmlElement -@QmlSingleton class QModListModel(QAbstractListModel): def __init__(self, parent=None) -> None: super().__init__(parent=parent) mods: list[QMod] = [] - mod_path: Path = GameFinder.find() / "mods" - - for dir in mod_path.iterdir(): + for dir in MOD_PATH.iterdir(): if dir.name == ".stfolder": continue try: @@ -48,16 +50,3 @@ class QModListModel(QAbstractListModel): else: result = None return result - - def removeRows(self, row, count, parent=QModelIndex()) -> bool: - super().beginRemoveRows(parent, row, row + count - 1) - for index in range(row, row + count): - deleted_mod: QMod = self.mods.pop(index) - for root, dirs, files in deleted_mod.pathlib_path.walk(top_down=False): - for name in files: - (root / name).unlink() - for name in dirs: - (root / name).rmdir() - deleted_mod.pathlib_path.rmdir() - super().endRemoveRows() - return False diff --git a/src/leek/qml/Main.qml b/src/leek/qml/Main.qml index cf6889b..a06690e 100644 --- a/src/leek/qml/Main.qml +++ b/src/leek/qml/Main.qml @@ -1,132 +1,30 @@ import QtQuick import QtQuick.Layouts import QtQuick.Controls as Controls -import QtQuick.Dialogs as Dialogs import org.kde.kirigami as Kirigami import Leek -import Leek.QModInstaller Kirigami.ApplicationWindow { id: root title: "Leek" - globalDrawer: Kirigami.GlobalDrawer { - actions: [ - Kirigami.Action { - // download-symbolic and install-symbolic are the same icon - // but install looks worse for some reason - id: addAction - icon.name: "download-symbolic" - text: "Add mod" - shortcut: StandardKey.New - onTriggered: modFileDialog.open() - }, - Kirigami.Action { - text: "Quit" - icon.name: "application-exit-symbolic" - shortcut: StandardKey.Quit - onTriggered: Qt.quit() - } - ] - isMenu: true - } - - Component.onCompleted: { - QModInstaller.statusChanged.connect(installerStatusChanged); - QModInstaller.finishedInstall.connect(installDialog.close); - } - - function installerStatusChanged() { - switch (QModInstaller.status) { - case "initialized": - if (QModInstaller.installed) { - installInfoMessage.text = "This mod is already installed!"; - installInfoDialog.open(); - } else { - installDialog.open(); - QModInstaller.install(); - } - break; - case "NoModExceptionError": - installInfoMessage.text = "This file does not have a mod"; - installInfoDialog.open(); - break; - case "UnsupportedArchiveTypeError": - installInfoMessage.text = "I don't know how to unpack this file, sorry :("; - installInfoDialog.open(); - break; - } - } - - Dialogs.FileDialog { - id: modFileDialog - - nameFilters: ["Project DIVA Mods (*.zip *.7z *.rar)"] - selectedNameFilter.index: 0 - - onAccepted: { - QModInstaller.modPath = selectedFile; - } - } - - Kirigami.Dialog { - id: installDialog - title: "Installing…" - // standardButtons: Kirigami.Dialog.Ok - closePolicy: Controls.Popup.NoAutoClose - showCloseButton: false - padding: Kirigami.Units.largeSpacing - Kirigami.LoadingPlaceholder { - // Layout.alignment: Qt.AlignCenter - anchors.centerIn: parent - determinate: false - text: "Unpacking…" - } - } - - Kirigami.Dialog { - id: installInfoDialog - title: "Add mod" - standardButtons: Kirigami.Dialog.Ok - padding: Kirigami.Units.largeSpacing - Controls.Label { - id: installInfoMessage - text: "You should never see this text" - } - } - - pageStack.columnView.columnResizeMode: Kirigami.ColumnView.SingleColumn pageStack.initialPage: Kirigami.ScrollablePage { title: "Mods" Kirigami.CardsListView { id: modsView - delegate: ModCardDelegate {} - model: QModListModel - - Kirigami.PlaceholderMessage { - anchors.centerIn: parent - icon.name: "edit-none" - visible: modsView.count === 0 - text: "There's no mods installed" - explanation: "Install a mod, and it will show up here" - helpfulAction: addAction + delegate: ModCardDelegate { + } + model: QModListModel { } } } component ModCardDelegate: Kirigami.AbstractCard { - required property int index required property QMod mod - showClickFeedback: true - onClicked: pageStack.push(Qt.resolvedUrl("ModPage.qml"), { - mod: mod, - index: index - }) - // headerOrientation: Qt.Horizontal contentItem: Item { implicitHeight: modCardLayout.implicitHeight @@ -183,6 +81,19 @@ Kirigami.ApplicationWindow { onClicked: mod.enabled = checked } + Controls.Button { + text: "Delete" + onClicked: notImplementedDialog.open() + Kirigami.Dialog { + id: notImplementedDialog + title: "Not implemented!" + standardButtons: Kirigami.Dialog.Ok + padding: Kirigami.Units.largeSpacing + Controls.Label { + text: "Deleting is not implemented yet" + } + } + } } } } diff --git a/src/leek/qml/ModPage.qml b/src/leek/qml/ModPage.qml deleted file mode 100644 index 528affb..0000000 --- a/src/leek/qml/ModPage.qml +++ /dev/null @@ -1,139 +0,0 @@ -import QtQuick -import QtQuick.Layouts -import QtQuick.Controls as Controls -import org.kde.kirigami as Kirigami -import Leek - -Kirigami.Page { - id: root - required property int index - required property QMod mod - - title: "Local mod" - - onBackRequested: event => { - event.accepted = true; - applicationWindow().pageStack.pop(); - } - - Kirigami.Dialog { - id: deleteConfirmationDialog - title: "Delete mod" - standardButtons: Kirigami.Dialog.Ok | Kirigami.Dialog.Cancel - padding: Kirigami.Units.largeSpacing - Controls.Label { - text: "Permanently delete this mod?" - } - onAccepted: { - QModListModel.removeRows(index, 1); - applicationWindow().pageStack.pop(); - } - onOpened: { - const deleteButton = standardButton(Kirigami.Dialog.Cancel); - deleteButton.forceActiveFocus(); - } - } - - actions: [ - Kirigami.Action { - text: "Delete" - icon.name: "delete-symbolic" - shortcut: StandardKey.Delete - onTriggered: deleteConfirmationDialog.open() - } - ] - - ColumnLayout { - anchors { - top: parent.top - left: parent.left - right: parent.right - } - - Rectangle { - id: header - Layout.fillWidth: true - - Layout.topMargin: -root.topPadding - Layout.leftMargin: -root.leftPadding - Layout.rightMargin: -root.rightPadding - - Kirigami.ImageColors { - id: iconColors - source: modIcon.source - } - - implicitHeight: headerContents.implicitHeight + (headerContents.anchors.topMargin * 2) - - // Tint the header with the dominant color of the mod's icon - color: Kirigami.ColorUtils.tintWithAlpha(Kirigami.Theme.backgroundColor, iconColors.dominant, 0.1) - - GridLayout { - id: headerContents - anchors { - top: parent.top - left: parent.left - right: parent.right - topMargin: root.padding - bottomMargin: root.padding - leftMargin: root.padding - rightMargin: root.padding - } - - RowLayout { - // Icon - Kirigami.Icon { - id: modIcon - implicitHeight: Kirigami.Units.iconSizes.huge - implicitWidth: implicitHeight - source: "package-x-generic" - } - - // Name and author - ColumnLayout { - Kirigami.Heading { - text: mod.name - type: Kirigami.Heading.Type.Primary - } - Controls.Label { - property bool hasAuthors: mod.authors.length > 0 - function joinAuthors() { - return mod.authors.join(", "); - } - - text: hasAuthors ? joinAuthors() : "Unknown author" - } - } - } - } - - Kirigami.Separator { - width: header.width - anchors.top: header.bottom - } - } - - // Description - ColumnLayout { - Kirigami.Heading { - // TODO: this should be a short description of some sort - text: mod.name - type: Kirigami.Heading.Type.Primary - } - Controls.Label { - readonly property bool hasDescription: mod.longDescription || mod.description - text: { - if (hasDescription) { - if (mod.longDescription) { - text = mod.longDescription; - } else { - text = mod.description; - } - } else { - text = "The description for this mod is not available"; - } - } - } - } - } -} diff --git a/src/leek/qmod.py b/src/leek/qmod.py index 1131468..a67c1dc 100644 --- a/src/leek/qmod.py +++ b/src/leek/qmod.py @@ -28,14 +28,6 @@ class QMod(QObject): # Pass though all exceptions raise - @property - def pathlib_path(self) -> Path: - return self.__mod.path - - @Property(str, constant=True) - def path(self) -> str: - return str(self.__mod.path) - @Property(str, constant=True) def name(self) -> str | None: return self.__mod.name @@ -48,10 +40,6 @@ class QMod(QObject): def longDescription(self) -> str | None: return self.__mod.long_description - @Property(list, constant=True) - def authors(self) -> list[str] | None: - return self.__mod.authors - mod_enabled = Signal(name="enabled") @Property(bool, notify=mod_enabled) diff --git a/src/leek/qmod_installer.py b/src/leek/qmod_installer.py deleted file mode 100644 index 7d7939f..0000000 --- a/src/leek/qmod_installer.py +++ /dev/null @@ -1,93 +0,0 @@ -from pathlib import Path -from urllib.parse import urlparse - -from PySide6.QtCore import Property, QObject, QRunnable, QThreadPool, Signal, Slot -from PySide6.QtQml import QmlElement, QmlSingleton - -from leek.mod_installer import ( - ModInstaller, - NoModExceptionError, - UnsupportedArchiveTypeError, -) - -QML_IMPORT_NAME = "Leek.QModInstaller" -QML_IMPORT_MAJOR_VERSION = 1 - - -# Qt follows C++ naming conventions -# ruff: noqa: N802 -@QmlElement -@QmlSingleton -class QModInstaller(QObject): - """ - Qt Wrapper around the ModInstaller() class - """ - - __mod_installer: ModInstaller - __status: str - - def __init__(self, parent=None): - super().__init__(parent=parent) - self.__status = "uninitialized" - - mod_path_changed = Signal(name="modPathChanged") - - @Property(str, notify=mod_path_changed) - def modPath(self) -> str: - return self.test - - @modPath.setter # type: ignore[no-redef] - def modPath(self, value: str) -> None: - try: - # In python 3.13 pathlib gained a from_uri() method which would be very useful - parsed_path: str = urlparse(value, "file").path - self.__mod_installer = ModInstaller(Path(parsed_path)) - self.__status = "initialized" - self.status_changed.emit() - except UnsupportedArchiveTypeError: - self.__status = "UnsupportedArchiveTypeError" - self.status_changed.emit() - except NoModExceptionError: - self.__status = "NoModExceptionError" - self.status_changed.emit() - - install_status_changed = Signal(name="installStatusChanged") - - @Property(bool, notify=install_status_changed) - def installed(self) -> bool: - return self.__mod_installer.is_installed - - status_changed = Signal(name="statusChanged") - - @Property(str, notify=status_changed) - def status(self) -> str: - return self.__status - - finished_install = Signal(name="finishedInstall") - - @Slot() - def install(self) -> None: - worker = InstallWorker(InstallWorkerSignals(), self.__mod_installer) - worker.signals.installed.connect(self.finished_install) - QThreadPool.globalInstance().start(worker) - - -class InstallWorker(QRunnable): - __installer: ModInstaller - - def __init__(self, signals, installer: ModInstaller) -> None: - super().__init__() - self.signals = signals - self.__installer = installer - - # @Slot() - def run(self) -> None: - self.__installer.install() - self.signals.installed.emit() - - -class InstallWorkerSignals(QObject): - def __init__(self): - super().__init__() - - installed = Signal() diff --git a/src/leek/utils.py b/src/leek/utils.py deleted file mode 100644 index c1d0bac..0000000 --- a/src/leek/utils.py +++ /dev/null @@ -1,32 +0,0 @@ -import vdf -from pathlib import Path - - -class GameFinder: - __game_path: Path | None = None - - @staticmethod - def find() -> Path: - if GameFinder.__game_path is not None: - return GameFinder.__game_path - - # .local/share/Steam/config/libraryfolders.vdf - steam_path: Path = Path.home() / ".local/share/Steam" - libraries_vdf_path: Path = steam_path / "config/libraryfolders.vdf" - - with libraries_vdf_path.open() as vdf_file: - data: dict = vdf.parse(vdf_file) - for index in data["libraryfolders"]: - library: dict = data["libraryfolders"][index] - - project_diva_id: str = "1761390" - if project_diva_id in library["apps"]: - libray_path: Path = Path(library["path"]) - GameFinder.__game_path = ( - libray_path - / "steamapps/common/Hatsune Miku Project DIVA Mega Mix Plus" - ) - return GameFinder.__game_path - - # Could not find the game :( - raise FileNotFoundError