Compare commits

...
Sign in to create a new pull request.

109 commits

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
36f0c20985 Nix: update version 2025-05-31 01:54:35 +02:00
ff6ce97df2 Revert "Nix: set package version to git rev"
This reverts commit bd6068d263.
2025-05-31 01:33:19 +02:00
ef634f7ae7 Pyproject: automatically source version 2025-05-30 23:50:27 +02:00
cbaa46f263 Flake: add setuptools-scm to dev environment 2025-05-30 23:50:27 +02:00
9e057c5023 Pyproject: require setuptools-scm 2025-05-30 23:50:27 +02:00
1286dc24fd Nix package: get setuptools-scm 2025-05-30 23:50:27 +02:00
9be4489f9b Remove MANIFEST.in 2025-05-30 23:50:27 +02:00
76fba3ed84 Mod: return list of authors
Closes #13
2025-05-30 21:57:15 +02:00
bb858ef261 Mod, QMod: add long description property 2025-05-30 13:02:05 +02:00
da8749e1a7 Mod: read name, description and authors from json if available 2025-05-30 13:02:05 +02:00
bfefcd757e Mod: parse meta.json if it exists 2025-05-30 13:02:05 +02:00
56165d0cef Mod: remove unneded else statement 2025-05-30 13:02:05 +02:00
bd6068d263 Nix: set package version to git rev 2025-05-30 12:44:04 +02:00
f07bd10306 Qml/main: add a not implemented dialog to the delete button 2025-05-30 12:13:33 +02:00
ad4f429cd9 Move qml files to their own folder 2025-05-29 18:01:02 +02:00
20f459ddad Main: center and remove text on switch 2025-05-29 18:01:02 +02:00
b1fbd4dea6 QMod: remove leftover debug message 2025-05-28 17:42:27 +02:00
f111950c65 QModListModel: ignore .stfolder
Fixes #9
2025-05-28 17:34:55 +02:00
af77bc105d QMod: make enable property writable 2025-05-28 17:34:43 +02:00
999cf68655 Mod: fix mod enabling/disabling multiple times
I forgot to update the enabled variable, so this would only work the
first time it gets called
2025-05-28 17:04:17 +02:00
f3650c10a9 Format code 2025-05-28 10:50:13 +02:00
669dd9168d QModListModel: return QMod to qml instead of individual properties 2025-05-28 10:42:23 +02:00
058a8346bd Add Mod() Qt wrapper 2025-05-28 10:15:38 +02:00
ad10a116ac Main.qml: Make the no description text italic 2025-05-27 10:53:51 +02:00
5cfab64b07 mod: use Path instead of strings 2025-05-25 14:31:46 +02:00
6918e175c4 mod_list: use Path instad of strings 2025-05-25 14:24:40 +02:00
39fa631592 Main: replace busy indicator with icon
This will be the mod's preview image in the future
2025-05-23 02:21:39 +02:00
fc37977002 leek_app: ignore unused import warning 2025-05-23 02:07:34 +02:00
868eebad2a Main: refactor, wire switch to modEnabled property 2025-05-23 02:03:11 +02:00
454c761a9d QModListModel: rename enabled to modEnabled
enabled seems to conflict with some builtin qml value
2025-05-23 02:00:43 +02:00
0850a23b35 Mod: read toml with get() 2025-05-23 01:50:27 +02:00
54dedaba5d Mod: fix enabled property 2025-05-23 01:47:19 +02:00
de7ebe2e51 Mod: add type annotations 2025-05-23 01:42:14 +02:00
0a52a6fcc9 List mods from QModListModel instead of hardcoded list 2025-05-23 01:01:33 +02:00
6061590270 Move source code to src/leek
This makes the package work again
2025-05-23 00:31:14 +02:00
1a626fddff Fix __main__.py 2025-05-22 15:34:37 +02:00
0a5cb83274 Add qml mod list model 2025-05-22 12:40:21 +02:00
049acf68be Mod: throw InvalidModError when config.toml doesn't exist 2025-05-19 22:24:04 +02:00
dce9e5fcdc Properly set application name and desktop file 2025-05-16 19:33:18 +02:00
620acbeb30 Add basic mod listing 2025-05-16 17:19:40 +02:00
ef25e8a026 Mod: make mypy happy
Moved the enabled setter next to the property, and made type hints
more accurate
2025-05-14 17:37:13 +02:00
3a8406cb19 Mod: return enabled as a bool instead of str 2025-05-14 17:33:59 +02:00
8349e4f340 Add src to mypy path 2025-05-14 17:23:06 +02:00
fb81e2bd1f Make ruff happier 2025-05-14 16:25:30 +02:00
9d4b229b44 Enable more ruff rules 2025-05-14 16:12:45 +02:00
64fd7d47e6 Set minimum supported python version 2025-05-14 16:03:34 +02:00
d53fe7160f Ignore missing type info from pyside 2025-05-14 14:50:46 +02:00
bc6c17b940 Flake: add mypy and pylsp-mypy 2025-05-13 21:00:06 +02:00
b5c6b33c68 Flake: make shell with no compiler 2025-05-11 14:57:57 +02:00
a549e9a99c Add Mod class 2025-05-07 00:17:46 +02:00
796f0aa209 Add dependencies to pyproject, add tomlkit 2025-05-07 00:16:25 +02:00
34cf1da185 Update gitignore 2025-05-07 00:13:44 +02:00
010deddc8e Set desktop file and app name 2025-05-06 20:19:00 +02:00
02621b79aa Flake: replace c++ tooling with python tooling in devshell 2025-05-06 16:41:13 +02:00
34b8610432 Revert "Temporarely disable direnv"
This reverts commit aaa6df32b4.
2025-05-06 16:36:09 +02:00
6a4272502e Add nix package 2025-05-06 16:25:12 +02:00
1575222c9d Rename main.py, fix qml path 2025-05-06 16:22:21 +02:00
786be23b6e Make project importable 2025-05-06 13:11:03 +02:00
792ee5c281 Turn into python project 2025-05-06 13:08:25 +02:00
404d8e0f18 Add python code 2025-05-06 12:55:40 +02:00
3707c44450 Update gitignore 2025-05-06 12:46:32 +02:00
aaa6df32b4 Temporarely disable direnv 2025-05-06 12:39:42 +02:00
3412339fe5 Remove cmake files and c++ code 2025-05-06 12:37:41 +02:00
19 changed files with 996 additions and 228 deletions

111
.gitignore vendored
View file

@ -1,93 +1,3 @@
# ---> C++
# Prerequisites
*.d
# Compiled Object files
*.slo
*.lo
*.o
*.obj
# Precompiled Headers
*.gch
*.pch
# Compiled Dynamic libraries
*.so
*.dylib
*.dll
# Fortran module files
*.mod
*.smod
# Compiled Static libraries
*.lai
*.la
*.a
*.lib
# Executables
*.exe
*.out
*.app
# ---> Qt
# C++ objects and libs
*.slo
*.lo
*.o
*.a
*.la
*.lai
*.so
*.so.*
*.dll
*.dylib
# Qt-es
object_script.*.Release
object_script.*.Debug
*_plugin_import.cpp
/.qmake.cache
/.qmake.stash
*.pro.user
*.pro.user.*
*.qbs.user
*.qbs.user.*
*.moc
moc_*.cpp
moc_*.h
qrc_*.cpp
ui_*.h
*.qmlc
*.jsc
Makefile*
*build-*
*.qm
*.prl
# Qt unit tests
target_wrapper.*
# QtCreator
*.autosave
# QtCreator Qml
*.qmlproject.user
*.qmlproject.user.*
# QtCreator CMake
CMakeLists.txt.user*
# QtCreator 4.8< compilation database
compile_commands.json
# QtCreator local machine specific files for imported projects
*creator.user*
*_qmlcache.qrc
# ---> Nix
# Ignore build outputs from performing a nix-build or `nix build` command
result
@ -96,21 +6,6 @@ result-*
# ---> Direnv
.direnv
# ---> CMake
CMakeLists.txt.user
CMakeCache.txt
CMakeFiles
CMakeScripts
Testing
Makefile
cmake_install.cmake
install_manifest.txt
compile_commands.json
CTestTestfile.cmake
_deps
CMakeUserPresets.json
build
# ---> Clangd
.cache/clangd
# ---> Python
.venv
__pycache__

View file

@ -1,28 +0,0 @@
cmake_minimum_required(VERSION 3.20)
project(leek)
find_package(ECM 6.0.0 REQUIRED NO_MODULE)
set(CMAKE_MODULE_PATH ${ECM_MODULE_PATH})
set(CMAKE_EXPORT_COMPILE_COMMANDS TRUE)
include(KDEInstallDirs)
include(KDECMakeSettings)
include(KDECompilerSettings NO_POLICY_SCOPE)
include(ECMFindQmlModule)
include(ECMQmlModule)
find_package(Qt6 REQUIRED COMPONENTS Core Quick Gui QuickControls2 Widgets)
find_package(KF6 REQUIRED COMPONENTS Kirigami I18n CoreAddons QQC2DesktopStyle
IconThemes)
# Don't mark this as required, since qml module finding is broken on NixOS
ecm_find_qmlmodule(org.kde.kirigami)
add_subdirectory(src)
install(PROGRAMS xyz.toast003.leek.desktop DESTINATION ${KDE_INSTALL_APPDIR})
feature_summary(WHAT ALL INCLUDE_QUIET_PACKAGES
FATAL_ON_MISSING_REQUIRED_PACKAGES)

View file

@ -3,31 +3,36 @@
inputs.nixpkgs.url = "nixpkgs/nixos-unstable";
outputs = { nixpkgs, ... }:
let
pkgs = nixpkgs.legacyPackages.x86_64-linux;
lib = nixpkgs.lib;
in
{
devShells.x86_64-linux.default = pkgs.mkShell {
name = "leek-devshell";
packages =
let
kdeDeps = with pkgs.kdePackages; [
extra-cmake-modules
qtbase
qtdeclarative
kirigami
ki18n
kcoreaddons
qqc2-desktop-style
];
in
with pkgs; [
clang-tools
cmake
] ++ kdeDeps;
};
};
}
outputs = {nixpkgs, ...}: let
pkgs = nixpkgs.legacyPackages.x86_64-linux;
lib = nixpkgs.lib;
in {
devShells.x86_64-linux.default = pkgs.mkShellNoCC {
name = "leek-devshell";
packages = with pkgs; [
ruff
(
python3.withPackages (
ps:
with ps; [
# Dev dependencies
python-lsp-server
pylsp-mypy
mypy
setuptools-scm
#App dependencies
pyside6
tomlkit
vdf
]
)
)
];
};
packages.x86_64-linux = rec {
default = leek;
leek = pkgs.callPackage ./package.nix {};
};
};
}

35
package.nix Normal file
View file

@ -0,0 +1,35 @@
{
kdePackages,
python3Packages,
qt6,
}:
python3Packages.buildPythonApplication rec {
pname = "leek";
version = "0.3";
pyproject = true;
src = ./.;
build-system = with python3Packages; [
setuptools
setuptools-scm
];
dependencies = with python3Packages; [
pyside6
tomlkit
vdf
];
nativeBuildInputs = [
qt6.wrapQtAppsHook
];
propagatedBuildInputs = [
kdePackages.kirigami
];
makeWrapperArgs = [
"\${qtWrapperArgs[@]}"
];
}

60
pyproject.toml Normal file
View file

@ -0,0 +1,60 @@
[build-system]
requires = ["setuptools", "setuptools-scm"]
build-backend = "setuptools.build_meta"
[project]
name = "leek"
dynamic = ["version"]
authors = [{name = "Toast"}]
description = "Project diva megamix + mod manager"
license = "MIT"
classifiers = [
"Intended Audience :: End Users/Desktop",
"Topic :: Utilities",
"Programming Language :: Python",
"Operating System :: POSIX :: Linux",
]
dependencies = [
"pyside6",
"tomlkit",
"vdf",
]
requires-python = ">=3.12"
[project.readme]
file = "README.md"
content-type = "text/markdown"
[project.scripts]
leek = "leek.leek_app:main"
[tool.ruff.lint]
select = [
# Defaults
"E4",
"E7",
"E9",
"F",
"W", # Pycodestyle warning
"N" # Pep-8 naming
]
[tool.mypy]
mypy_path = "$MYPY_CONFIG_FILE_DIR/src"
# Pyside 6 doesn't have type info so it's better to just ignore it
[[tool.mypy.overrides]]
module = [
"PySide6.QtGui",
"PySide6.QtCore",
"PySide6.QtQml",
"vdf"
]
ignore_missing_imports = true
[tool.setuptools.data-files]
"share/applications" = ["xyz.toast003.leek.desktop"]
[tool.setuptools_scm]

View file

@ -1,13 +0,0 @@
add_executable(leek)
ecm_add_qml_module(leek URI xyz.toast003.leek)
target_sources(leek PRIVATE main.cpp)
ecm_target_qml_sources(leek SOURCES Main.qml)
target_link_libraries(leek PRIVATE Qt6::Quick Qt6::Qml Qt6::Gui
Qt6::QuickControls2 Qt6::Widgets)
target_link_libraries(leek PRIVATE KF6::I18n KF6::CoreAddons KF6::IconThemes)
install(TARGETS leek ${KDE_INSTALL_TARGETS_DEFAULT_ARGS})

View file

@ -1,17 +0,0 @@
import QtQuick
import QtQuick.Layouts
import QtQuick.Controls as Controls
import org.kde.kirigami as Kirigami
Kirigami.ApplicationWindow {
id: root
title: "Leek"
pageStack.initialPage: Kirigami.Page {
Controls.Label {
anchors.centerIn: parent
text: "Hello world"
}
}
}

0
src/leek/__init__.py Normal file
View file

14
src/leek/__main__.py Normal file
View file

@ -0,0 +1,14 @@
# https://packaging.python.org/en/latest/discussions/src-layout-vs-flat-layout/#running-a-command-line-interface-from-source-with-src-layout
import os
import sys
if not __package__:
# Make CLI runnable from source tree with
# python src/package
package_source_path = os.path.dirname(os.path.dirname(__file__))
sys.path.insert(0, package_source_path)
from leek import leek_app
leek_app.main()

38
src/leek/leek_app.py Normal file
View file

@ -0,0 +1,38 @@
#!/usr/bin/env python3
import os
import sys
import signal
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: QApplication = QApplication(sys.argv)
app.setDesktopFileName("xyz.toast003.leek")
app.setApplicationName("Leek")
engine = QQmlApplicationEngine()
"""Needed to close the app with Ctrl+C"""
signal.signal(signal.SIGINT, signal.SIG_DFL)
"""Needed to get proper KDE style outside of Plasma"""
if not os.environ.get("QT_QUICK_CONTROLS_STYLE"):
os.environ["QT_QUICK_CONTROLS_STYLE"] = "org.kde.desktop"
base_path = os.path.abspath(os.path.dirname(__file__))
url = QUrl(f"file://{base_path}/qml/Main.qml")
engine.load(url)
if len(engine.rootObjects()) == 0:
quit()
app.exec()
if __name__ == "__main__":
main()

115
src/leek/mod.py Normal file
View file

@ -0,0 +1,115 @@
import tomlkit
import json
from pathlib import Path
from tomlkit import TOMLDocument
from typing import Any
class Mod:
__config: TOMLDocument
__has_meta_json: bool
__meta: dict[str, Any]
__path: Path
__name: str
__description: str
__author: str
__enabled: bool
@property
def path(self) -> Path:
return self.__path
# Mod metadata
@property
def name(self) -> str | None:
if self.__has_meta_json:
if "name" in self.__meta:
return self.__meta.get("name", str)
if "name" not in self.__config:
return None
return self.__config.get("name", str)
@property
def description(self) -> str | None:
if self.__has_meta_json:
if "description" in self.__meta:
return self.__meta.get("description", str)
if "description" not in self.__config.keys():
return None
else:
return self.__config.get("description", str)
@property
def long_description(self) -> str | None:
if "descriptionLong" in self.__meta:
return self.__meta.get("descriptionLong", str)
return None
@property
def authors(self) -> list[str] | None:
if self.__has_meta_json:
if "authors" in self.__meta:
return self.__meta.get("authors", list[str])
if "author" not in self.__config:
return None
return [self.__config.get("author", str)]
@property
def enabled(self) -> bool:
return self.__enabled
@enabled.setter
def enabled(self, value: bool) -> None:
if value == self.__enabled:
# Nothing to do
return
config_toml = Path(self.__path, "config.toml")
with config_toml.open("w") as config_file:
self.__config["enabled"] = value
tomlkit.dump(self.__config, config_file)
self.__enabled = value
def __init__(self, path: Path) -> None:
self.__path = path
self.__meta = {}
try:
config_toml = Path(self.__path, "config.toml")
with config_toml.open() as config_file:
self.__config = tomlkit.load(config_file)
if "enabled" not in self.__config:
raise InvalidModError("config.toml does not contain the enabled key")
self.__enabled = self.__config.get("enabled", bool)
except FileNotFoundError:
raise InvalidModError("config.toml does not exist")
meta_json: Path = Path(self.__path, "meta.json")
if meta_json.exists():
self.__has_meta_json = True
try:
with meta_json.open() as file:
self.__meta = json.load(file)
except json.JSONDecodeError as e:
print("Failed to parse meta.json!: ", e.msg)
else:
self.__has_meta_json = False
def __str__(self) -> str:
return f"Mod({self.__path})"
class InvalidModError(Exception):
"""
This exception is raised when the Mod class gets given a path of something that's not a valid mod
"""
def __init__(self, message: str) -> None:
super().__init__(message)
self.message = message
def __str__(self) -> str:
return f"{self.message}"

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

63
src/leek/mod_list.py Normal file
View file

@ -0,0 +1,63 @@
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
# 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] = []
mod_path: Path = GameFinder.find() / "mods"
for dir in mod_path.iterdir():
if dir.name == ".stfolder":
continue
try:
new_mod: QMod = QMod(dir, self)
mods.append(new_mod)
except InvalidModError as e:
print(f"Found invalid mod at {dir}: {e.message}")
continue
self.mods = mods
def roleNames(self) -> dict[int, bytes]:
return {0: b"mod"}
def rowCount(self, parent=QModelIndex()) -> int:
return len(self.mods)
def data(self, index: QModelIndex, role: int) -> None | QMod:
i: int = index.row()
result: None | QMod
if not index.isValid():
result = None
elif role == 0:
result = self.mods[i]
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

190
src/leek/qml/Main.qml Normal file
View file

@ -0,0 +1,190 @@
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
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
implicitWidth: modCardLayout.implicitWidth
GridLayout {
id: modCardLayout
columnSpacing: Kirigami.Units.largeSpacing
columns: root.wideScreen ? 4 : 2
rowSpacing: Kirigami.Units.largeSpacing
anchors {
left: parent.left
right: parent.right
top: parent.top
}
// TODO: Replace this with an image once we can get them
Kirigami.Padding {
padding: Kirigami.Units.largeSpacing
contentItem: Kirigami.Icon {
implicitHeight: Kirigami.Units.iconSizes.huge
implicitWidth: implicitHeight
source: "package-x-generic"
}
}
ColumnLayout {
Kirigami.Heading {
Layout.fillWidth: true
text: mod.name
type: Kirigami.Heading.Type.Primary
}
Kirigami.Separator {
Layout.fillWidth: true
}
Controls.Label {
property string desc: mod.description
Layout.fillWidth: true
font.italic: desc ? false : true
text: desc ? desc : "No description available"
wrapMode: Text.WordWrap
}
}
ColumnLayout {
Layout.alignment: Qt.AlignRight
Controls.Switch {
checked: mod.enabled
Layout.alignment: Qt.AlignCenter
onClicked: mod.enabled = checked
}
}
}
}
}
}

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

64
src/leek/qmod.py Normal file
View file

@ -0,0 +1,64 @@
from pathlib import Path
from PySide6.QtCore import Property, QObject, Signal, Slot
from PySide6.QtQml import QmlElement, QmlUncreatable
from leek.mod import Mod
QML_IMPORT_NAME = "Leek"
QML_IMPORT_MAJOR_VERSION = 1
# Qt follows C++ naming conventions
# ruff: noqa: N802
@QmlElement
@QmlUncreatable("QMod is intended to be returned by a QModListModel")
class QMod(QObject):
"""
Qt wrapper around the Mod() class
"""
__mod: Mod
def __init__(self, mod_path: Path, parent=None) -> None:
QObject.__init__(self, parent)
try:
self.__mod = Mod(mod_path)
except:
# 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
@Property(str, constant=True)
def description(self) -> str | None:
return self.__mod.description
@Property(str, constant=True)
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)
def enabled(self) -> bool:
return self.__mod.enabled
@enabled.setter # type: ignore[no-redef]
def enabled(self, value: bool) -> None:
self.__mod.enabled = value
self.mod_enabled.emit()

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

View file

@ -1,35 +0,0 @@
#include <KIconTheme>
#include <KLocalizedContext>
#include <KLocalizedString>
#include <QApplication>
#include <QQmlApplicationEngine>
#include <QQuickStyle>
#include <QUrl>
#include <QtQml>
#include <qapplication.h>
int main(int argc, char *argv[]) {
KIconTheme::initTheme();
QApplication app(argc, argv);
KLocalizedString::setApplicationDomain("leek");
QApplication::setOrganizationName(QStringLiteral("Toast"));
QApplication::setOrganizationDomain(QStringLiteral("toast003.xyz"));
QApplication::setApplicationName(QStringLiteral("Leek"));
QApplication::setDesktopFileName(QStringLiteral("xyz.toast003.leek"));
QApplication::setStyle(QStringLiteral("breeze"));
if (qEnvironmentVariableIsEmpty("QT_QUICK_CONTROLS_STYLE")) {
QQuickStyle::setStyle(QStringLiteral("org.kde.desktop"));
}
QQmlApplicationEngine engine;
engine.rootContext()->setContextObject(new KLocalizedContext(&engine));
engine.loadFromModule("xyz.toast003.leek", "Main");
if (engine.rootObjects().isEmpty()) {
return -1;
}
return app.exec();
}