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"; inputs.nixpkgs.url = "nixpkgs/nixos-unstable";
outputs = { nixpkgs, ... }: outputs = {nixpkgs, ...}: let
let
pkgs = nixpkgs.legacyPackages.x86_64-linux; pkgs = nixpkgs.legacyPackages.x86_64-linux;
lib = nixpkgs.lib; lib = nixpkgs.lib;
in in {
{
devShells.x86_64-linux.default = pkgs.mkShellNoCC { devShells.x86_64-linux.default = pkgs.mkShellNoCC {
name = "leek-devshell"; name = "leek-devshell";
packages = with pkgs; [ packages = with pkgs; [
ruff ruff
( (
python3.withPackages (ps: with ps;[ python3.withPackages (
ps:
with ps; [
# Dev dependencies # Dev dependencies
python-lsp-server python-lsp-server
pylsp-mypy pylsp-mypy
@ -32,8 +32,7 @@
}; };
packages.x86_64-linux = rec { packages.x86_64-linux = rec {
default = leek; default = leek;
leek = pkgs.callPackage ./package.nix { }; leek = pkgs.callPackage ./package.nix {};
}; };
}; };
} }

View file

@ -1,10 +1,11 @@
{ {
kdePackages, kdePackages,
python3Packages, python3Packages,
qt6 qt6,
}: python3Packages.buildPythonApplication rec { }:
python3Packages.buildPythonApplication rec {
pname = "leek"; pname = "leek";
version = "0.2"; version = "0.3";
pyproject = true; pyproject = true;
src = ./.; src = ./.;
@ -14,7 +15,6 @@
setuptools-scm setuptools-scm
]; ];
dependencies = with python3Packages; [ dependencies = with python3Packages; [
pyside6 pyside6
tomlkit tomlkit

View file

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

View file

@ -7,6 +7,7 @@ from PySide6.QtWidgets import QApplication
from PySide6.QtCore import QUrl from PySide6.QtCore import QUrl
from PySide6.QtQml import QQmlApplicationEngine from PySide6.QtQml import QQmlApplicationEngine
from leek.mod_list import QAbstractListModel # noqa: F401, needs to be imported for QML 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(): 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 PySide6.QtCore import QAbstractListModel, QModelIndex
from leek.mod import InvalidModError from leek.mod import InvalidModError
from leek.qmod import QMod from leek.qmod import QMod
@ -12,6 +12,7 @@ QML_IMPORT_MAJOR_VERSION = 1
# Qt follows C++ naming conventions # Qt follows C++ naming conventions
# ruff: noqa: N802 # ruff: noqa: N802
@QmlElement @QmlElement
@QmlSingleton
class QModListModel(QAbstractListModel): class QModListModel(QAbstractListModel):
def __init__(self, parent=None) -> None: def __init__(self, parent=None) -> None:
super().__init__(parent=parent) super().__init__(parent=parent)
@ -47,3 +48,16 @@ class QModListModel(QAbstractListModel):
else: else:
result = None result = None
return result 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
import QtQuick.Layouts import QtQuick.Layouts
import QtQuick.Controls as Controls import QtQuick.Controls as Controls
import QtQuick.Dialogs as Dialogs
import org.kde.kirigami as Kirigami import org.kde.kirigami as Kirigami
import Leek import Leek
import Leek.QModInstaller
Kirigami.ApplicationWindow { Kirigami.ApplicationWindow {
id: root id: root
title: "Leek" 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 { pageStack.initialPage: Kirigami.ScrollablePage {
title: "Mods" title: "Mods"
Kirigami.CardsListView { Kirigami.CardsListView {
id: modsView id: modsView
delegate: ModCardDelegate { delegate: ModCardDelegate {}
} model: QModListModel
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 { component ModCardDelegate: Kirigami.AbstractCard {
required property int index
required property QMod mod required property QMod mod
showClickFeedback: true 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 // headerOrientation: Qt.Horizontal
contentItem: Item { contentItem: Item {
@ -84,19 +183,6 @@ Kirigami.ApplicationWindow {
onClicked: mod.enabled = checked 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 { Kirigami.Page {
id: root id: root
required property int index
required property QMod mod required property QMod mod
title: "Local 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 { ColumnLayout {
anchors { anchors {
top: parent.top top: parent.top
@ -33,11 +66,7 @@ Kirigami.Page {
implicitHeight: headerContents.implicitHeight + (headerContents.anchors.topMargin * 2) implicitHeight: headerContents.implicitHeight + (headerContents.anchors.topMargin * 2)
// Tint the header with the dominant color of the mod's icon // Tint the header with the dominant color of the mod's icon
color: Kirigami.ColorUtils.tintWithAlpha( color: Kirigami.ColorUtils.tintWithAlpha(Kirigami.Theme.backgroundColor, iconColors.dominant, 0.1)
Kirigami.Theme.backgroundColor,
iconColors.dominant,
0.1
)
GridLayout { GridLayout {
id: headerContents id: headerContents
@ -69,7 +98,7 @@ Kirigami.Page {
Controls.Label { Controls.Label {
property bool hasAuthors: mod.authors.length > 0 property bool hasAuthors: mod.authors.length > 0
function joinAuthors() { 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: { text: {
if (hasDescription) { if (hasDescription) {
if (mod.longDescription) { if (mod.longDescription) {
text = mod.longDescription text = mod.longDescription;
} else { } else {
text = mod.description text = mod.description;
} }
} else { } 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 # Pass though all exceptions
raise 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) @Property(str, constant=True)
def name(self) -> str | None: def name(self) -> str | None:
return self.__mod.name 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()