Compare commits

...

31 commits
v0.2 ... main

Author SHA1 Message Date
bf1f622196 Nix: bump version 2025-06-06 19:22:14 +02:00
8be97c1f5b Qml/Main: clean up mod install logic 2025-06-06 17:59:51 +02:00
fa8421ff42 QModInstaller: actually install mod 2025-06-06 17:36:50 +02:00
9ed941b37e QModInstaller: add install slot
Install is done in a separate thread so that the UI can keep updating
2025-06-06 17:14:13 +02:00
e7d487c180 Qml/Main: add install dialog 2025-06-06 16:11:17 +02:00
53f0341d98 Qml/Main: add icon and action to placeholder 2025-06-06 04:26:16 +02:00
0cd631c92c Qml/Main: add error handling to installation 2025-06-06 04:18:44 +02:00
e94d11f138 Qml/Main; add mod file picker dialog 2025-06-06 04:18:44 +02:00
035c333dcf Add QModInstaller class 2025-06-06 04:18:44 +02:00
b0cebe12a0 Qml/Main: move add mod action to global drawer 2025-06-06 04:18:44 +02:00
0ad0b3e605 ModInstaller: check if mod is already installed 2025-06-06 04:18:44 +02:00
a130a23a8a Qml/main: add add mod action 2025-06-06 04:18:44 +02:00
56b63fe673 ModInstaller: add install method 2025-06-06 04:18:44 +02:00
6290a18c52 Add ModInstaller class 2025-06-06 04:18:44 +02:00
bd2713fe8d Qml/Main: show placeholder if there's no mods 2025-06-06 04:17:08 +02:00
8b40e82c36 Pyproject: bump minimum python version
We use pathlib.walk() now
2025-06-06 04:05:56 +02:00
95364a5596 Qml/ModPage: implement deleting 2025-06-06 04:02:38 +02:00
4dba9ad425 Qml/ModPage: add delete shortcut 2025-06-06 04:00:46 +02:00
2bda69ba4b Qml/ModPage: remove model property
QModListModel is a singleton now so this isn't needed
2025-06-06 04:00:46 +02:00
7ac5f7470e Qml/ModPage: complete delete confirmation dialog 2025-06-06 03:47:56 +02:00
b61d1d1fa9 Qml/ModPage: fix remove action 2025-06-06 03:47:56 +02:00
5baa7840de Qml/ModPage: get model and index from mod 2025-06-06 03:47:56 +02:00
3024eb9a3d Qml: Move delete dialog and button to mod details page 2025-06-06 03:47:56 +02:00
a2789b1704 QModList: implement removing mods 2025-06-06 03:47:56 +02:00
cf8e77d844 QMod: expose paths
The pathlib path is only exposed to python
2025-06-06 03:47:56 +02:00
1061acbe6b QModListModel: turn into singleton 2025-06-06 03:47:17 +02:00
631cdb4938 Qml/Main: add shortcut to quit action 2025-06-05 22:49:52 +02:00
174f845b2c Nix: format with alejandra 2025-06-05 13:25:48 +02:00
15a00512a8 Qml: run qmlformat 2025-06-05 13:23:47 +02:00
e17cc20cdb Qml/Main: add global drawer 2025-06-05 13:22:54 +02:00
79ef083231 Show mod details page in same window 2025-06-05 13:06:46 +02:00
10 changed files with 412 additions and 64 deletions

View file

@ -3,18 +3,18 @@
inputs.nixpkgs.url = "nixpkgs/nixos-unstable";
outputs = { nixpkgs, ... }:
let
outputs = {nixpkgs, ...}: let
pkgs = nixpkgs.legacyPackages.x86_64-linux;
lib = nixpkgs.lib;
in
{
in {
devShells.x86_64-linux.default = pkgs.mkShellNoCC {
name = "leek-devshell";
packages = with pkgs; [
ruff
(
python3.withPackages (ps: with ps;[
python3.withPackages (
ps:
with ps; [
# Dev dependencies
python-lsp-server
pylsp-mypy
@ -36,4 +36,3 @@
};
};
}

View file

@ -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

View file

@ -19,7 +19,7 @@ dependencies = [
"tomlkit",
"vdf",
]
requires-python = ">=3.6"
requires-python = ">=3.12"
[project.readme]
file = "README.md"

View file

@ -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():

118
src/leek/mod_installer.py Normal file
View file

@ -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}"

View file

@ -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

View file

@ -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"
}
}
}
}
}
}

View file

@ -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,7 +98,7 @@ 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"
@ -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";
}
}
}

View file

@ -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

View file

@ -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()