Compare commits

...

46 commits
v0.1 ... 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
a757e31cee Nix: bump version 2025-06-05 11:49:37 +02:00
42943a4bd5 Qml/ModPage: show description 2025-06-05 11:33:18 +02:00
446ca73a91 Qml/ModPage: make name bold and bigger, show authors 2025-06-05 02:25:07 +02:00
4edfdc8706 QMod: Add authors property 2025-06-05 02:24:23 +02:00
14964e0203 Qml/ModPage: tint header with icon's primary color 2025-06-05 01:55:56 +02:00
1ab178ac42 Qml/ModPage: change anchors of header contents 2025-06-05 00:57:20 +02:00
bda1040151 Qml/ModPage: add icon
The icon will change depending on the type of mod that was installed
Only if it was installed from GameBanana, otherwise it will show
dma's logo or a generic one
2025-06-05 00:51:33 +02:00
2f715e196c Qml/ModPage: add a separator at the bottom of the header
Also removed the one pixed gap cause it made it look odd
2025-06-05 00:39:57 +02:00
b3b1c7b4c6 Qml/main: open mod details page when card is clicked 2025-06-05 00:35:31 +02:00
2ad0e0597c Qml: add ModPage 2025-06-05 00:23:16 +02:00
d33d91c0be Leek-app: switch to QApplication
This fixes the near constant console spam
2025-06-04 02:10:20 +02:00
31b9f1f0a1 Nix package: add forgotten vdf dependency
Whoops!
2025-06-02 01:28:14 +02:00
f2cc48f705 QModListModel: get game path with GameFinder 2025-06-02 01:24:52 +02:00
67edfdf743 Add GameFinder class 2025-06-02 01:21:55 +02:00
426e771313 Add vdf dependency 2025-06-02 00:10:28 +02:00
11 changed files with 563 additions and 62 deletions

View file

@ -3,24 +3,28 @@
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
mypy
setuptools-scm
#App dependencies
pyside6
tomlkit
vdf
]
)
)
@ -32,4 +36,3 @@
};
};
}

View file

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

View file

@ -17,8 +17,9 @@ classifiers = [
dependencies = [
"pyside6",
"tomlkit",
"vdf",
]
requires-python = ">=3.6"
requires-python = ">=3.12"
[project.readme]
file = "README.md"
@ -47,7 +48,8 @@ mypy_path = "$MYPY_CONFIG_FILE_DIR/src"
module = [
"PySide6.QtGui",
"PySide6.QtCore",
"PySide6.QtQml"
"PySide6.QtQml",
"vdf"
]
ignore_missing_imports = true

View file

@ -3,15 +3,16 @@
import os
import sys
import signal
from PySide6.QtGui import QGuiApplication
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():
"""Initializes and manages the application execution"""
app: QGuiApplication = QGuiApplication(sys.argv)
app: QApplication = QApplication(sys.argv)
app.setDesktopFileName("xyz.toast003.leek")
app.setApplicationName("Leek")
engine = QQmlApplicationEngine()

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,28 +1,26 @@
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
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] = []
for dir in MOD_PATH.iterdir():
mod_path: Path = GameFinder.find() / "mods"
for dir in mod_path.iterdir():
if dir.name == ".stfolder":
continue
try:
@ -50,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,30 +1,132 @@
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.push(Qt.resolvedUrl("ModPage.qml"), {
mod: mod,
index: index
})
// headerOrientation: Qt.Horizontal
contentItem: Item {
implicitHeight: modCardLayout.implicitHeight
@ -81,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"
}
}
}
}
}
}

139
src/leek/qml/ModPage.qml Normal file
View file

@ -0,0 +1,139 @@
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";
}
}
}
}
}
}

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
@ -40,6 +48,10 @@ 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)

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

32
src/leek/utils.py Normal file
View file

@ -0,0 +1,32 @@
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