diff --git a/flake.nix b/flake.nix index bbdb95c..8424972 100644 --- a/flake.nix +++ b/flake.nix @@ -3,37 +3,36 @@ 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 + 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 - ] - ) + #App dependencies + pyside6 + tomlkit + vdf + ] ) - ]; - }; - 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 987b8f4..48f4f64 100644 --- a/package.nix +++ b/package.nix @@ -1,10 +1,11 @@ { kdePackages, python3Packages, - qt6 -}: python3Packages.buildPythonApplication rec { + qt6, +}: +python3Packages.buildPythonApplication rec { pname = "leek"; - version = "0.2"; + version = "0.3"; pyproject = true; src = ./.; @@ -14,7 +15,6 @@ setuptools-scm ]; - dependencies = with python3Packages; [ pyside6 tomlkit @@ -27,7 +27,7 @@ propagatedBuildInputs = [ kdePackages.kirigami - ]; + ]; makeWrapperArgs = [ "\${qtWrapperArgs[@]}" diff --git a/pyproject.toml b/pyproject.toml index b0cc6ce..1ccbed4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -19,7 +19,7 @@ dependencies = [ "tomlkit", "vdf", ] -requires-python = ">=3.6" +requires-python = ">=3.12" [project.readme] file = "README.md" diff --git a/src/leek/leek_app.py b/src/leek/leek_app.py index 701046c..f85592e 100644 --- a/src/leek/leek_app.py +++ b/src/leek/leek_app.py @@ -7,6 +7,7 @@ from PySide6.QtWidgets import QApplication 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(): diff --git a/src/leek/mod_installer.py b/src/leek/mod_installer.py new file mode 100644 index 0000000..485d93f --- /dev/null +++ b/src/leek/mod_installer.py @@ -0,0 +1,118 @@ +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 ee12488..3c13d58 100644 --- a/src/leek/mod_list.py +++ b/src/leek/mod_list.py @@ -1,4 +1,4 @@ -from PySide6.QtQml import QmlElement +from PySide6.QtQml import QmlElement, QmlSingleton from PySide6.QtCore import QAbstractListModel, QModelIndex from leek.mod import InvalidModError from leek.qmod import QMod @@ -12,6 +12,7 @@ QML_IMPORT_MAJOR_VERSION = 1 # Qt follows C++ naming conventions # ruff: noqa: N802 @QmlElement +@QmlSingleton class QModListModel(QAbstractListModel): def __init__(self, parent=None) -> None: super().__init__(parent=parent) @@ -47,3 +48,16 @@ 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 adec38c..cf6889b 100644 --- a/src/leek/qml/Main.qml +++ b/src/leek/qml/Main.qml @@ -1,32 +1,131 @@ 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 { + 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 } } } component ModCardDelegate: Kirigami.AbstractCard { + required property int index required property QMod mod showClickFeedback: true - onClicked: pageStack.pushDialogLayer(Qt.resolvedUrl("ModPage.qml"), {mod: mod}) + onClicked: pageStack.push(Qt.resolvedUrl("ModPage.qml"), { + mod: mod, + index: index + }) // headerOrientation: Qt.Horizontal contentItem: Item { @@ -84,19 +183,6 @@ 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 index ab1e5d3..528affb 100644 --- a/src/leek/qml/ModPage.qml +++ b/src/leek/qml/ModPage.qml @@ -6,10 +6,43 @@ 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 @@ -33,11 +66,7 @@ Kirigami.Page { 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 - ) + color: Kirigami.ColorUtils.tintWithAlpha(Kirigami.Theme.backgroundColor, iconColors.dominant, 0.1) GridLayout { id: headerContents @@ -69,10 +98,10 @@ Kirigami.Page { Controls.Label { property bool hasAuthors: mod.authors.length > 0 function joinAuthors() { - return mod.authors.join(", ") + return mod.authors.join(", "); } - text: hasAuthors ? joinAuthors() : "Unknown author" + text: hasAuthors ? joinAuthors() : "Unknown author" } } } @@ -96,12 +125,12 @@ Kirigami.Page { text: { if (hasDescription) { if (mod.longDescription) { - text = mod.longDescription + text = mod.longDescription; } else { - text = mod.description + text = mod.description; } } else { - text = "The description for this mod is not available" + text = "The description for this mod is not available"; } } } diff --git a/src/leek/qmod.py b/src/leek/qmod.py index 1e12bc4..1131468 100644 --- a/src/leek/qmod.py +++ b/src/leek/qmod.py @@ -28,6 +28,14 @@ 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 diff --git a/src/leek/qmod_installer.py b/src/leek/qmod_installer.py new file mode 100644 index 0000000..7d7939f --- /dev/null +++ b/src/leek/qmod_installer.py @@ -0,0 +1,93 @@ +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()