Compare commits

..

No commits in common. "main" and "v0.1" have entirely different histories.
main ... v0.1

11 changed files with 60 additions and 561 deletions

View file

@ -3,28 +3,24 @@
inputs.nixpkgs.url = "nixpkgs/nixos-unstable"; inputs.nixpkgs.url = "nixpkgs/nixos-unstable";
outputs = {nixpkgs, ...}: let outputs = { nixpkgs, ... }:
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 ( python3.withPackages (ps: with ps;[
ps:
with ps; [
# Dev dependencies
python-lsp-server python-lsp-server
pylsp-mypy pylsp-mypy
mypy mypy
setuptools-scm setuptools-scm
#App dependencies
pyside6 pyside6
tomlkit tomlkit
vdf
] ]
) )
) )
@ -36,3 +32,4 @@
}; };
}; };
} }

View file

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

View file

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

View file

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

View file

@ -1,118 +0,0 @@
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,26 +1,28 @@
from PySide6.QtQml import QmlElement, QmlSingleton from PySide6.QtQml import QmlElement
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
from pathlib import Path from pathlib import Path
from leek.utils import GameFinder
QML_IMPORT_NAME = "Leek" QML_IMPORT_NAME = "Leek"
QML_IMPORT_MAJOR_VERSION = 1 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 # 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)
mods: list[QMod] = [] mods: list[QMod] = []
mod_path: Path = GameFinder.find() / "mods" for dir in MOD_PATH.iterdir():
for dir in mod_path.iterdir():
if dir.name == ".stfolder": if dir.name == ".stfolder":
continue continue
try: try:
@ -48,16 +50,3 @@ 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,132 +1,30 @@
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
onClicked: pageStack.push(Qt.resolvedUrl("ModPage.qml"), {
mod: mod,
index: index
})
// headerOrientation: Qt.Horizontal // headerOrientation: Qt.Horizontal
contentItem: Item { contentItem: Item {
implicitHeight: modCardLayout.implicitHeight implicitHeight: modCardLayout.implicitHeight
@ -183,6 +81,19 @@ 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

@ -1,139 +0,0 @@
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,14 +28,6 @@ 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
@ -48,10 +40,6 @@ class QMod(QObject):
def longDescription(self) -> str | None: def longDescription(self) -> str | None:
return self.__mod.long_description return self.__mod.long_description
@Property(list, constant=True)
def authors(self) -> list[str] | None:
return self.__mod.authors
mod_enabled = Signal(name="enabled") mod_enabled = Signal(name="enabled")
@Property(bool, notify=mod_enabled) @Property(bool, notify=mod_enabled)

View file

@ -1,93 +0,0 @@
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()

View file

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