From b4743432c2ad5632d9839db394ac0baa26714893 Mon Sep 17 00:00:00 2001 From: Toast Date: Thu, 5 Jun 2025 13:45:52 +0200 Subject: [PATCH 01/30] Qml/Main: enable word wrap on mod card name --- src/leek/qml/Main.qml | 1 + 1 file changed, 1 insertion(+) diff --git a/src/leek/qml/Main.qml b/src/leek/qml/Main.qml index 9b6b912..eec15e4 100644 --- a/src/leek/qml/Main.qml +++ b/src/leek/qml/Main.qml @@ -74,6 +74,7 @@ Kirigami.ApplicationWindow { Layout.fillWidth: true text: mod.name type: Kirigami.Heading.Type.Primary + wrapMode: Text.WordWrap } Kirigami.Separator { Layout.fillWidth: true From 8494196ad7789d4d05777669eb15cc49fd28baa6 Mon Sep 17 00:00:00 2001 From: Toast Date: Thu, 5 Jun 2025 14:09:38 +0200 Subject: [PATCH 02/30] Qml/Main: add maximun text lines, tooltip for truncated mod names --- src/leek/qml/Main.qml | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/src/leek/qml/Main.qml b/src/leek/qml/Main.qml index eec15e4..5f00b63 100644 --- a/src/leek/qml/Main.qml +++ b/src/leek/qml/Main.qml @@ -75,6 +75,19 @@ Kirigami.ApplicationWindow { text: mod.name type: Kirigami.Heading.Type.Primary wrapMode: Text.WordWrap + maximumLineCount: 2 + elide: Text.ElideRight + + // https://stackoverflow.com/a/50504960 + Controls.ToolTip.visible: labelArea.containsMouse && truncated + Controls.ToolTip.text: mod.name + Controls.ToolTip.delay: Kirigami.Units.toolTipDelay + + MouseArea { + id: labelArea + anchors.fill: parent + hoverEnabled: true + } } Kirigami.Separator { Layout.fillWidth: true @@ -86,6 +99,9 @@ Kirigami.ApplicationWindow { font.italic: desc ? false : true text: desc ? desc : "No description available" wrapMode: Text.WordWrap + + maximumLineCount: 6 + elide: Text.ElideRight } } ColumnLayout { From 1bf00174a50760ac0febadb6105d1219653eacb6 Mon Sep 17 00:00:00 2001 From: Toast Date: Thu, 5 Jun 2025 20:19:15 +0200 Subject: [PATCH 03/30] Qml/Main: connect mousearea clicked signal to card --- src/leek/qml/Main.qml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/leek/qml/Main.qml b/src/leek/qml/Main.qml index 5f00b63..667eb01 100644 --- a/src/leek/qml/Main.qml +++ b/src/leek/qml/Main.qml @@ -35,6 +35,7 @@ Kirigami.ApplicationWindow { component ModCardDelegate: Kirigami.AbstractCard { required property QMod mod + id: card showClickFeedback: true onClicked: pageStack.push(Qt.resolvedUrl("ModPage.qml"), { mod: mod @@ -87,6 +88,11 @@ Kirigami.ApplicationWindow { id: labelArea anchors.fill: parent hoverEnabled: true + // For some reason I can't connect the pressed signals, so there's no feedback for that + // Whoops + Component.onCompleted: { + clicked.connect(card.clicked) + } } } Kirigami.Separator { From 631cdb493873b7fa82ade2c5754101892de6dc93 Mon Sep 17 00:00:00 2001 From: Toast Date: Thu, 5 Jun 2025 22:49:52 +0200 Subject: [PATCH 04/30] Qml/Main: add shortcut to quit action --- src/leek/qml/Main.qml | 1 + 1 file changed, 1 insertion(+) diff --git a/src/leek/qml/Main.qml b/src/leek/qml/Main.qml index 9b6b912..a7650ee 100644 --- a/src/leek/qml/Main.qml +++ b/src/leek/qml/Main.qml @@ -14,6 +14,7 @@ Kirigami.ApplicationWindow { Kirigami.Action { text: "Quit" icon.name: "application-exit-symbolic" + shortcut: StandardKey.Quit onTriggered: Qt.quit() } ] From 1061acbe6bbeefcad092a51ad1e7043f837e9775 Mon Sep 17 00:00:00 2001 From: Toast Date: Thu, 5 Jun 2025 21:32:41 +0200 Subject: [PATCH 05/30] QModListModel: turn into singleton --- src/leek/mod_list.py | 3 ++- src/leek/qml/Main.qml | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/src/leek/mod_list.py b/src/leek/mod_list.py index ee12488..96a98d4 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) diff --git a/src/leek/qml/Main.qml b/src/leek/qml/Main.qml index a7650ee..da54ba4 100644 --- a/src/leek/qml/Main.qml +++ b/src/leek/qml/Main.qml @@ -29,7 +29,7 @@ Kirigami.ApplicationWindow { id: modsView delegate: ModCardDelegate {} - model: QModListModel {} + model: QModListModel } } From cf8e77d844436756dc486eef040043bb1bfaaf78 Mon Sep 17 00:00:00 2001 From: Toast Date: Fri, 6 Jun 2025 03:05:38 +0200 Subject: [PATCH 06/30] QMod: expose paths The pathlib path is only exposed to python --- src/leek/qmod.py | 8 ++++++++ 1 file changed, 8 insertions(+) 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 From a2789b1704278ca354f61e142ecc5e1ede29d9bf Mon Sep 17 00:00:00 2001 From: Toast Date: Fri, 6 Jun 2025 03:16:06 +0200 Subject: [PATCH 07/30] QModList: implement removing mods --- src/leek/mod_list.py | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/src/leek/mod_list.py b/src/leek/mod_list.py index 96a98d4..3c13d58 100644 --- a/src/leek/mod_list.py +++ b/src/leek/mod_list.py @@ -48,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 From 3024eb9a3da4a40a6a7a5fd4d91d619d9dfb23a6 Mon Sep 17 00:00:00 2001 From: Toast Date: Fri, 6 Jun 2025 03:29:30 +0200 Subject: [PATCH 08/30] Qml: Move delete dialog and button to mod details page --- src/leek/qml/Main.qml | 13 ------------- src/leek/qml/ModPage.qml | 18 ++++++++++++++++++ 2 files changed, 18 insertions(+), 13 deletions(-) diff --git a/src/leek/qml/Main.qml b/src/leek/qml/Main.qml index da54ba4..e211d4a 100644 --- a/src/leek/qml/Main.qml +++ b/src/leek/qml/Main.qml @@ -97,19 +97,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 326b760..cbe0ec5 100644 --- a/src/leek/qml/ModPage.qml +++ b/src/leek/qml/ModPage.qml @@ -15,6 +15,24 @@ Kirigami.Page { applicationWindow().pageStack.pop(); } + Kirigami.Dialog { + id: notImplementedDialog + title: "Not implemented" + standardButtons: Kirigami.Dialog.Ok + padding: Kirigami.Units.largeSpacing + Controls.Label { + text: "Deleting is not implemented yet" + } + } + + actions: [ + Kirigami.Action { + text: "Remove" + icon.name: "delete-symbolic" + onToggled: notImplementedDialog.open() + } + ] + ColumnLayout { anchors { top: parent.top From 5baa7840de1eb71f1cf67f9727c1328b899736fb Mon Sep 17 00:00:00 2001 From: Toast Date: Fri, 6 Jun 2025 03:32:43 +0200 Subject: [PATCH 09/30] Qml/ModPage: get model and index from mod --- src/leek/qml/Main.qml | 5 ++++- src/leek/qml/ModPage.qml | 2 ++ 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/src/leek/qml/Main.qml b/src/leek/qml/Main.qml index e211d4a..7f41e88 100644 --- a/src/leek/qml/Main.qml +++ b/src/leek/qml/Main.qml @@ -34,11 +34,14 @@ Kirigami.ApplicationWindow { } component ModCardDelegate: Kirigami.AbstractCard { + required property int index required property QMod mod showClickFeedback: true onClicked: pageStack.push(Qt.resolvedUrl("ModPage.qml"), { - mod: mod + mod: mod, + index: index, + model: modsView.model }) // headerOrientation: Qt.Horizontal diff --git a/src/leek/qml/ModPage.qml b/src/leek/qml/ModPage.qml index cbe0ec5..d29a273 100644 --- a/src/leek/qml/ModPage.qml +++ b/src/leek/qml/ModPage.qml @@ -6,6 +6,8 @@ import Leek Kirigami.Page { id: root + required property QModListModel model + required property int index required property QMod mod title: "Local mod" From b61d1d1fa9a31d7197feb69490815051c487abc5 Mon Sep 17 00:00:00 2001 From: Toast Date: Fri, 6 Jun 2025 03:34:30 +0200 Subject: [PATCH 10/30] Qml/ModPage: fix remove action --- src/leek/qml/ModPage.qml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/leek/qml/ModPage.qml b/src/leek/qml/ModPage.qml index d29a273..96dce42 100644 --- a/src/leek/qml/ModPage.qml +++ b/src/leek/qml/ModPage.qml @@ -31,7 +31,7 @@ Kirigami.Page { Kirigami.Action { text: "Remove" icon.name: "delete-symbolic" - onToggled: notImplementedDialog.open() + onTriggered: notImplementedDialog.open() } ] From 7ac5f7470e2afe84137d738b24e46fae183ba283 Mon Sep 17 00:00:00 2001 From: Toast Date: Fri, 6 Jun 2025 03:42:36 +0200 Subject: [PATCH 11/30] Qml/ModPage: complete delete confirmation dialog --- src/leek/qml/ModPage.qml | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/src/leek/qml/ModPage.qml b/src/leek/qml/ModPage.qml index 96dce42..00b3023 100644 --- a/src/leek/qml/ModPage.qml +++ b/src/leek/qml/ModPage.qml @@ -18,20 +18,24 @@ Kirigami.Page { } Kirigami.Dialog { - id: notImplementedDialog - title: "Not implemented" - standardButtons: Kirigami.Dialog.Ok + id: deleteConfirmationDialog + title: "Delete mod" + standardButtons: Kirigami.Dialog.Ok | Kirigami.Dialog.Cancel padding: Kirigami.Units.largeSpacing Controls.Label { - text: "Deleting is not implemented yet" + text: "Permanently delete this mod?" + } + onOpened: { + const deleteButton = standardButton(Kirigami.Dialog.Cancel) + deleteButton.forceActiveFocus() } } actions: [ Kirigami.Action { - text: "Remove" + text: "Delete" icon.name: "delete-symbolic" - onTriggered: notImplementedDialog.open() + onTriggered: deleteConfirmationDialog.open() } ] From 2bda69ba4b502eee3ba8e5e12eaa7aa89a0670e5 Mon Sep 17 00:00:00 2001 From: Toast Date: Fri, 6 Jun 2025 03:53:36 +0200 Subject: [PATCH 12/30] Qml/ModPage: remove model property QModListModel is a singleton now so this isn't needed --- src/leek/qml/Main.qml | 3 +-- src/leek/qml/ModPage.qml | 1 - 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/src/leek/qml/Main.qml b/src/leek/qml/Main.qml index 7f41e88..87dde4f 100644 --- a/src/leek/qml/Main.qml +++ b/src/leek/qml/Main.qml @@ -40,8 +40,7 @@ Kirigami.ApplicationWindow { showClickFeedback: true onClicked: pageStack.push(Qt.resolvedUrl("ModPage.qml"), { mod: mod, - index: index, - model: modsView.model + index: index }) // headerOrientation: Qt.Horizontal diff --git a/src/leek/qml/ModPage.qml b/src/leek/qml/ModPage.qml index 00b3023..4aa2093 100644 --- a/src/leek/qml/ModPage.qml +++ b/src/leek/qml/ModPage.qml @@ -6,7 +6,6 @@ import Leek Kirigami.Page { id: root - required property QModListModel model required property int index required property QMod mod From 4dba9ad425f5fd52acbb081d0e72a16dbfdca37f Mon Sep 17 00:00:00 2001 From: Toast Date: Fri, 6 Jun 2025 03:59:27 +0200 Subject: [PATCH 13/30] Qml/ModPage: add delete shortcut --- src/leek/qml/ModPage.qml | 1 + 1 file changed, 1 insertion(+) diff --git a/src/leek/qml/ModPage.qml b/src/leek/qml/ModPage.qml index 4aa2093..a68a3b6 100644 --- a/src/leek/qml/ModPage.qml +++ b/src/leek/qml/ModPage.qml @@ -34,6 +34,7 @@ Kirigami.Page { Kirigami.Action { text: "Delete" icon.name: "delete-symbolic" + shortcut: StandardKey.Delete onTriggered: deleteConfirmationDialog.open() } ] From 95364a55963ab71762626365d74003dbbbe1959b Mon Sep 17 00:00:00 2001 From: Toast Date: Fri, 6 Jun 2025 04:02:38 +0200 Subject: [PATCH 14/30] Qml/ModPage: implement deleting --- src/leek/qml/ModPage.qml | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/leek/qml/ModPage.qml b/src/leek/qml/ModPage.qml index a68a3b6..528affb 100644 --- a/src/leek/qml/ModPage.qml +++ b/src/leek/qml/ModPage.qml @@ -24,9 +24,13 @@ Kirigami.Page { Controls.Label { text: "Permanently delete this mod?" } + onAccepted: { + QModListModel.removeRows(index, 1); + applicationWindow().pageStack.pop(); + } onOpened: { - const deleteButton = standardButton(Kirigami.Dialog.Cancel) - deleteButton.forceActiveFocus() + const deleteButton = standardButton(Kirigami.Dialog.Cancel); + deleteButton.forceActiveFocus(); } } From 8b40e82c3692f4fae4fe2638137ef2cfad4cd6d2 Mon Sep 17 00:00:00 2001 From: Toast Date: Fri, 6 Jun 2025 04:05:56 +0200 Subject: [PATCH 15/30] Pyproject: bump minimum python version We use pathlib.walk() now --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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" From bd2713fe8dd08ea76a0de765aab4cf10f7e75351 Mon Sep 17 00:00:00 2001 From: Toast Date: Fri, 6 Jun 2025 04:17:08 +0200 Subject: [PATCH 16/30] Qml/Main: show placeholder if there's no mods --- src/leek/qml/Main.qml | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/leek/qml/Main.qml b/src/leek/qml/Main.qml index 87dde4f..d26e7a9 100644 --- a/src/leek/qml/Main.qml +++ b/src/leek/qml/Main.qml @@ -30,6 +30,14 @@ Kirigami.ApplicationWindow { delegate: ModCardDelegate {} model: QModListModel + + Kirigami.PlaceholderMessage { + anchors.centerIn: parent + + visible: modsView.count === 0 + text: "There's no mods installed" + explanation: "Install a mod, and it will show up here" + } } } From 6290a18c5257c8ce99fce98a6ac5493042939897 Mon Sep 17 00:00:00 2001 From: Toast Date: Sun, 1 Jun 2025 23:53:25 +0200 Subject: [PATCH 17/30] Add ModInstaller class --- src/leek/mod_installer.py | 74 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 74 insertions(+) create mode 100644 src/leek/mod_installer.py diff --git a/src/leek/mod_installer.py b/src/leek/mod_installer.py new file mode 100644 index 0000000..f837778 --- /dev/null +++ b/src/leek/mod_installer.py @@ -0,0 +1,74 @@ +from pathlib import Path +from zipfile import Path as ZipPath +from zipfile import ZipFile, is_zipfile + + +class ModInstaller: + __archive_format: str + + def __init__(self, mod_path: Path) -> None: + 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 + return + case _: + config_toml = path / "config.toml" + if config_toml.exists() and config_toml.is_file(): + # Mod needs to be extracted in a folder + 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)) + + def install(self) -> None: + """ + Install the mod to Project Diva's mod folder + """ + raise NotImplementedError + + +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}" From 56b63fe6736bc24ed3c5019966b9b1da3b791557 Mon Sep 17 00:00:00 2001 From: Toast Date: Tue, 3 Jun 2025 00:33:25 +0200 Subject: [PATCH 18/30] ModInstaller: add install method --- src/leek/mod_installer.py | 26 +++++++++++++++++++++++++- 1 file changed, 25 insertions(+), 1 deletion(-) diff --git a/src/leek/mod_installer.py b/src/leek/mod_installer.py index f837778..dcbde74 100644 --- a/src/leek/mod_installer.py +++ b/src/leek/mod_installer.py @@ -1,12 +1,18 @@ 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) @@ -22,11 +28,13 @@ class ModInstaller: 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 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 return # If we ever get here there's either no mod on the archive, or we failed to find it @@ -35,11 +43,27 @@ class ModInstaller: 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 """ - raise NotImplementedError + if self.__is_installed: + return + + game_path: Path = GameFinder.find() + if self.__is_flat: + mod_folder_name: str = self.__archive_path.stem + 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 class UnsupportedArchiveTypeError(Exception): From a130a23a8af43a5b95bdf99ca768d7185063e9a0 Mon Sep 17 00:00:00 2001 From: Toast Date: Tue, 3 Jun 2025 01:48:32 +0200 Subject: [PATCH 19/30] Qml/main: add add mod action --- src/leek/qml/Main.qml | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/leek/qml/Main.qml b/src/leek/qml/Main.qml index d26e7a9..fcad804 100644 --- a/src/leek/qml/Main.qml +++ b/src/leek/qml/Main.qml @@ -25,6 +25,15 @@ Kirigami.ApplicationWindow { pageStack.initialPage: Kirigami.ScrollablePage { title: "Mods" + actions: [ + Kirigami.Action { + // download-symbolic and install-symbolic are the same icon + // but install looks worse for some reason + icon.name: "download-symbolic" + text: "Add mod" + } + ] + Kirigami.CardsListView { id: modsView From 0ad0b3e605ff26ac4d45828cd4763249f86d1301 Mon Sep 17 00:00:00 2001 From: Toast Date: Thu, 5 Jun 2025 21:11:12 +0200 Subject: [PATCH 20/30] ModInstaller: check if mod is already installed --- src/leek/mod_installer.py | 22 +++++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) diff --git a/src/leek/mod_installer.py b/src/leek/mod_installer.py index dcbde74..485d93f 100644 --- a/src/leek/mod_installer.py +++ b/src/leek/mod_installer.py @@ -29,12 +29,14 @@ class ModInstaller: 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 @@ -56,7 +58,7 @@ class ModInstaller: game_path: Path = GameFinder.find() if self.__is_flat: - mod_folder_name: str = self.__archive_path.stem + mod_folder_name: str = self.__get_mod_folder_name() ZipFile(self.__archive_path).extractall( path=str(game_path / "mods" / mod_folder_name) ) @@ -65,6 +67,24 @@ class ModInstaller: 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): """ From b0cebe12a0cc019c7cf0fee9af3f10e2936d15b3 Mon Sep 17 00:00:00 2001 From: Toast Date: Thu, 5 Jun 2025 21:33:23 +0200 Subject: [PATCH 21/30] Qml/Main: move add mod action to global drawer --- src/leek/qml/Main.qml | 15 ++++++--------- 1 file changed, 6 insertions(+), 9 deletions(-) diff --git a/src/leek/qml/Main.qml b/src/leek/qml/Main.qml index fcad804..534cfed 100644 --- a/src/leek/qml/Main.qml +++ b/src/leek/qml/Main.qml @@ -11,6 +11,12 @@ Kirigami.ApplicationWindow { globalDrawer: Kirigami.GlobalDrawer { actions: [ + Kirigami.Action { + // download-symbolic and install-symbolic are the same icon + // but install looks worse for some reason + icon.name: "download-symbolic" + text: "Add mod" + }, Kirigami.Action { text: "Quit" icon.name: "application-exit-symbolic" @@ -25,15 +31,6 @@ Kirigami.ApplicationWindow { pageStack.initialPage: Kirigami.ScrollablePage { title: "Mods" - actions: [ - Kirigami.Action { - // download-symbolic and install-symbolic are the same icon - // but install looks worse for some reason - icon.name: "download-symbolic" - text: "Add mod" - } - ] - Kirigami.CardsListView { id: modsView From 035c333dcfb3a2c62882f982852860233f230ddb Mon Sep 17 00:00:00 2001 From: Toast Date: Fri, 6 Jun 2025 01:35:36 +0200 Subject: [PATCH 22/30] Add QModInstaller class --- src/leek/qmod_installer.py | 57 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 57 insertions(+) create mode 100644 src/leek/qmod_installer.py diff --git a/src/leek/qmod_installer.py b/src/leek/qmod_installer.py new file mode 100644 index 0000000..258ce4c --- /dev/null +++ b/src/leek/qmod_installer.py @@ -0,0 +1,57 @@ +from pathlib import Path +from urllib.parse import urlparse + +from PySide6.QtCore import Property, QObject, Signal +from PySide6.QtQml import QmlElement, QmlSingleton + +from leek.mod_installer import ModInstaller, 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() + + 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 From e94d11f138ad1534567f2d5da810a3163ac20532 Mon Sep 17 00:00:00 2001 From: Toast Date: Fri, 6 Jun 2025 01:36:54 +0200 Subject: [PATCH 23/30] Qml/Main; add mod file picker dialog --- src/leek/leek_app.py | 1 + src/leek/qml/Main.qml | 15 +++++++++++++++ 2 files changed, 16 insertions(+) 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/qml/Main.qml b/src/leek/qml/Main.qml index 534cfed..af2d497 100644 --- a/src/leek/qml/Main.qml +++ b/src/leek/qml/Main.qml @@ -1,8 +1,10 @@ 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 @@ -16,6 +18,8 @@ Kirigami.ApplicationWindow { // but install looks worse for some reason icon.name: "download-symbolic" text: "Add mod" + shortcut: StandardKey.New + onTriggered: modFileDialog.open() }, Kirigami.Action { text: "Quit" @@ -27,6 +31,17 @@ Kirigami.ApplicationWindow { isMenu: true } + Dialogs.FileDialog { + id: modFileDialog + + nameFilters: ["Project DIVA Mods (*.zip *.7z *.rar)"] + selectedNameFilter.index: 0 + + onAccepted: { + QModInstaller.modPath = selectedFile; + } + } + pageStack.columnView.columnResizeMode: Kirigami.ColumnView.SingleColumn pageStack.initialPage: Kirigami.ScrollablePage { title: "Mods" From 0cd631c92c6e1ab2c044495d82a16f7b4d682475 Mon Sep 17 00:00:00 2001 From: Toast Date: Fri, 6 Jun 2025 02:21:52 +0200 Subject: [PATCH 24/30] Qml/Main: add error handling to installation --- src/leek/qml/Main.qml | 30 ++++++++++++++++++++++++++++++ src/leek/qmod_installer.py | 5 ++++- 2 files changed, 34 insertions(+), 1 deletion(-) diff --git a/src/leek/qml/Main.qml b/src/leek/qml/Main.qml index af2d497..103b0e7 100644 --- a/src/leek/qml/Main.qml +++ b/src/leek/qml/Main.qml @@ -38,10 +38,40 @@ Kirigami.ApplicationWindow { selectedNameFilter.index: 0 onAccepted: { + installInfoDialog.open(); + QModInstaller.statusChanged.connect(() => { + switch (QModInstaller.status) { + case "initialized": + if (QModInstaller.installed) { + installInfoMessage.text = "This mod is already installed!"; + installInfoDialog.open(); + } + 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; + } + }); QModInstaller.modPath = selectedFile; } } + 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" diff --git a/src/leek/qmod_installer.py b/src/leek/qmod_installer.py index 258ce4c..7767ed5 100644 --- a/src/leek/qmod_installer.py +++ b/src/leek/qmod_installer.py @@ -4,7 +4,7 @@ from urllib.parse import urlparse from PySide6.QtCore import Property, QObject, Signal from PySide6.QtQml import QmlElement, QmlSingleton -from leek.mod_installer import ModInstaller, UnsupportedArchiveTypeError +from leek.mod_installer import ModInstaller, UnsupportedArchiveTypeError, NoModExceptionError QML_IMPORT_NAME = "Leek.QModInstaller" QML_IMPORT_MAJOR_VERSION = 1 @@ -43,6 +43,9 @@ class QModInstaller(QObject): except UnsupportedArchiveTypeError: self.__status = "UnsupportedArchiveTypeError" self.status_changed.emit() + except NoModExceptionError: + self.__status = "NoModExceptionError" + self.status_changed.emit() install_status_changed = Signal(name="installStatusChanged") From 53f0341d98b55fdee801ec934f6b21ee33942ed5 Mon Sep 17 00:00:00 2001 From: Toast Date: Fri, 6 Jun 2025 04:26:16 +0200 Subject: [PATCH 25/30] Qml/Main: add icon and action to placeholder --- src/leek/qml/Main.qml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/leek/qml/Main.qml b/src/leek/qml/Main.qml index 103b0e7..aa216a6 100644 --- a/src/leek/qml/Main.qml +++ b/src/leek/qml/Main.qml @@ -16,6 +16,7 @@ Kirigami.ApplicationWindow { 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 @@ -84,10 +85,11 @@ Kirigami.ApplicationWindow { 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 } } } From e7d487c18033dd30b016a32ca53caeaf9a4284dd Mon Sep 17 00:00:00 2001 From: Toast Date: Fri, 6 Jun 2025 16:11:17 +0200 Subject: [PATCH 26/30] Qml/Main: add install dialog --- src/leek/qml/Main.qml | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/src/leek/qml/Main.qml b/src/leek/qml/Main.qml index aa216a6..a4c70b9 100644 --- a/src/leek/qml/Main.qml +++ b/src/leek/qml/Main.qml @@ -39,13 +39,14 @@ Kirigami.ApplicationWindow { selectedNameFilter.index: 0 onAccepted: { - installInfoDialog.open(); QModInstaller.statusChanged.connect(() => { switch (QModInstaller.status) { case "initialized": if (QModInstaller.installed) { installInfoMessage.text = "This mod is already installed!"; installInfoDialog.open(); + } else { + installDialog.open(); } break; case "NoModExceptionError": @@ -62,6 +63,21 @@ Kirigami.ApplicationWindow { } } + 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" From 9ed941b37ef8012cb7997b779dd240c3e75c209c Mon Sep 17 00:00:00 2001 From: Toast Date: Fri, 6 Jun 2025 17:14:13 +0200 Subject: [PATCH 27/30] QModInstaller: add install slot Install is done in a separate thread so that the UI can keep updating --- src/leek/qmod_installer.py | 37 +++++++++++++++++++++++++++++++++++-- 1 file changed, 35 insertions(+), 2 deletions(-) diff --git a/src/leek/qmod_installer.py b/src/leek/qmod_installer.py index 7767ed5..9b3825b 100644 --- a/src/leek/qmod_installer.py +++ b/src/leek/qmod_installer.py @@ -1,10 +1,15 @@ +import time from pathlib import Path from urllib.parse import urlparse -from PySide6.QtCore import Property, QObject, Signal +from PySide6.QtCore import Property, QObject, QRunnable, QThreadPool, Signal, Slot from PySide6.QtQml import QmlElement, QmlSingleton -from leek.mod_installer import ModInstaller, UnsupportedArchiveTypeError, NoModExceptionError +from leek.mod_installer import ( + ModInstaller, + NoModExceptionError, + UnsupportedArchiveTypeError, +) QML_IMPORT_NAME = "Leek.QModInstaller" QML_IMPORT_MAJOR_VERSION = 1 @@ -58,3 +63,31 @@ class QModInstaller(QObject): @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()) + # worker.signals.installed.connect(self.mod_finished_installing) + worker.signals.installed.connect(self.finished_install) + QThreadPool.globalInstance().start(worker) + + +class InstallWorker(QRunnable): + def __init__(self, signals) -> None: + super().__init__() + self.signals = signals + + # @Slot() + def run(self) -> None: + # Fake installing for now + time.sleep(4) + self.signals.installed.emit() + + +class InstallWorkerSignals(QObject): + def __init__(self): + super().__init__() + + installed = Signal() From fa8421ff42cc605a342980bd1ae383dde3560a35 Mon Sep 17 00:00:00 2001 From: Toast Date: Fri, 6 Jun 2025 17:36:50 +0200 Subject: [PATCH 28/30] QModInstaller: actually install mod --- src/leek/qml/Main.qml | 2 ++ src/leek/qmod_installer.py | 12 ++++++------ 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/src/leek/qml/Main.qml b/src/leek/qml/Main.qml index a4c70b9..048a0b5 100644 --- a/src/leek/qml/Main.qml +++ b/src/leek/qml/Main.qml @@ -47,6 +47,8 @@ Kirigami.ApplicationWindow { installInfoDialog.open(); } else { installDialog.open(); + QModInstaller.finishedInstall.connect(() => installDialog.close()) + QModInstaller.install(); } break; case "NoModExceptionError": diff --git a/src/leek/qmod_installer.py b/src/leek/qmod_installer.py index 9b3825b..7d7939f 100644 --- a/src/leek/qmod_installer.py +++ b/src/leek/qmod_installer.py @@ -1,4 +1,3 @@ -import time from pathlib import Path from urllib.parse import urlparse @@ -68,21 +67,22 @@ class QModInstaller(QObject): @Slot() def install(self) -> None: - worker = InstallWorker(InstallWorkerSignals()) - # worker.signals.installed.connect(self.mod_finished_installing) + worker = InstallWorker(InstallWorkerSignals(), self.__mod_installer) worker.signals.installed.connect(self.finished_install) QThreadPool.globalInstance().start(worker) class InstallWorker(QRunnable): - def __init__(self, signals) -> None: + __installer: ModInstaller + + def __init__(self, signals, installer: ModInstaller) -> None: super().__init__() self.signals = signals + self.__installer = installer # @Slot() def run(self) -> None: - # Fake installing for now - time.sleep(4) + self.__installer.install() self.signals.installed.emit() From 8be97c1f5ba08810e3fc808d05d37cce8db256ee Mon Sep 17 00:00:00 2001 From: Toast Date: Fri, 6 Jun 2025 17:59:51 +0200 Subject: [PATCH 29/30] Qml/Main: clean up mod install logic --- src/leek/qml/Main.qml | 49 ++++++++++++++++++++++++------------------- 1 file changed, 27 insertions(+), 22 deletions(-) diff --git a/src/leek/qml/Main.qml b/src/leek/qml/Main.qml index 048a0b5..cf6889b 100644 --- a/src/leek/qml/Main.qml +++ b/src/leek/qml/Main.qml @@ -32,6 +32,33 @@ Kirigami.ApplicationWindow { 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 @@ -39,28 +66,6 @@ Kirigami.ApplicationWindow { selectedNameFilter.index: 0 onAccepted: { - QModInstaller.statusChanged.connect(() => { - switch (QModInstaller.status) { - case "initialized": - if (QModInstaller.installed) { - installInfoMessage.text = "This mod is already installed!"; - installInfoDialog.open(); - } else { - installDialog.open(); - QModInstaller.finishedInstall.connect(() => installDialog.close()) - 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; - } - }); QModInstaller.modPath = selectedFile; } } From bf1f62219675b69437155d84d521d12a9660dcd9 Mon Sep 17 00:00:00 2001 From: Toast Date: Fri, 6 Jun 2025 19:22:14 +0200 Subject: [PATCH 30/30] Nix: bump version --- package.nix | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.nix b/package.nix index b242333..48f4f64 100644 --- a/package.nix +++ b/package.nix @@ -5,7 +5,7 @@ }: python3Packages.buildPythonApplication rec { pname = "leek"; - version = "0.2"; + version = "0.3"; pyproject = true; src = ./.;