This file provides guidance for AI agents working on the JoystickGremlin codebase.
JoystickGremlin is a Python application (PySide6/QML) for configuring joystick devices on Windows. It uses vJoy for virtual joystick emulation and supports macros, modes, and Python scripting.
# Run all tests
poetry run pytest
# Run a single test file
poetry run pytest test/unit/test_profile.py
# Run a specific test
poetry run pytest test/unit/test_profile.py::test_simple_action
# Run tests with verbose output
poetry run pytest -v
# Run tests matching a pattern
poetry run pytest -k "test_simple"
# Run e2e tests only
poetry run pytest test/integration/
# Run action interaction tests only
poetry run pytest test/action_interaction/
# Run unit tests only
poetry run pytest test/unit/# Run ruff linter (ANN = annotations required)
poetry run ruff check .# Run pyright type checker
poetry run pyright# Run in dev mode
poetry run python joystick_gremlin.py- Keep related functionality together
- Follow the existing module organization:
gremlin/- Core application logicgremlin/ui/- UI-related code (PySide6/QML integration)action_plugins/- Action plugins, one directory per actiontest/- Test suitesqml/- QML UI filesvjoy/— vJoy virtual joystick ctypes wrapperdill/— native device input library (DILL.dll) with Python wrapper
joystick_gremlin.py— entry point; initializes devices, Qt engine, plugins
| Class | Module | Purpose |
|---|---|---|
Configuration |
gremlin.config |
Persistent app config |
EventListener |
gremlin.event_handler |
Raw input capture & routing |
ModeManager |
gremlin.mode_manager |
Mode stack & switching when Gremlin is active |
Backend |
gremlin.ui.backend |
Main QML↔Python bridge |
Use metaclass=common.SingletonMetaclass (not the legacy @common.SingletonDecorator).
Profiles are XML files. gremlin.profile.Profile owns load/save and dispatches this via various to_xml and from_xml call chains.
Backend(registered asbackendcontext property) exposes signals and slots to QML- QML-accessible classes use
@QtQml.QmlElementorengine.rootContext().setContextProperty() - Qt signals/slots use
@QtCore.Signal/@QtCore.Slotdecorators - Never call Qt GUI APIs from non-main threads
Every Python file must include:
# -*- coding: utf-8; -*-
# SPDX-License-Identifier: GPL-3.0-only- Use
from __future__ import annotationsfor forward references - Sort imports: stdlib, third-party, local (alphabetically within groups)
- Use absolute imports within the package (e.g.,
from gremlin.types import InputType) - Use
TYPE_CHECKINGguard for imports only needed for type hints to avoid circular imports
Example:
from __future__ import annotations
import logging
from typing import (
cast,
Any,
TYPE_CHECKING
)
from PySide6 import (
QtCore,
QtQml
)
import gremlin.profile
from gremlin.types import InputType
from gremlin.error import GremlinError
if TYPE_CHECKING:
from gremlin.base_classes import AbstractActionData- Required: All function signatures must have type annotations (enforced by ruff ANN rule)
- Use Python 3.13+ syntax:
list[str],dict[str, int](no need forList,Dictfrom typing) - Acceptable exceptions:
PySide6import errors (project-wide Pylance issue)AbstractActionDataattribute unknowns (project-wide)
- Use
X | NoneoverOptional[X]
- Classes:
PascalCase(e.g.,JoystickGremlinApp,Profile) - Functions/methods:
snake_case(e.g.,get_vjoy_device,from_xml) - Constants:
SCREAMING_SNAKE_CASE(e.g.,MAX_CACHE_SIZE) - Private members:
_leading_underscore(e.g.,_cache) - Qt properties/slots: Follow Qt conventions (e.g.,
imageReady,requestImage) - QML variables: Follow Qt convention (e.g.
someVariable) - QML element identifiers: Always start with
_(e.g._label,_actionModel)
- Use
@QtCore.Signaland@QtCore.Slotdecorators for Qt signals/slots - Signals are defined as class attributes:
imageReady = QtCore.Signal(str, str) - Subclass
QtCore.QObjectfor QML-accessible classes if no more specialized class is applicable - Use
@QtQml.QmlElementdecorator for classes to register with QML - Register QML context properties with
engine.rootContext().setContextProperty()to be used in exceptional circumstances only
from PySide6 import QtCore
if TYPE_CHECKING:
import gremlin.ui.type_aliases as ta
class DataProvider(QtCore.QObject):
dataReady = QtCore.Signal(str)
def __init__(self, parent: ta.OQO=None):
self._value = ""
def _get_value(self) -> str:
return self._value
def _set_value(self, val: str) -> None:
self._value = val
self.dataReady.emit(val)
value = QtCore.Property(
str,
fget=_get_value,
fset=_set_value,
notify=dataReady
)- Custom exceptions are defined in
gremlin.errorand inherit fromGremlinError - Use specific exception types (e.g.,
ProfileError,VJoyError,MissingImplementationError) - Provide meaningful error messages
- Exceptions are caught on the highest level and logged there
Example:
from gremlin.error import GremlinError
try:
value = int(text)
except ValueError:
raise GremlinError(f"Invalid device index: {index}")- Use
logging.getLogger("system")for logging
- Never use Qt GUI classes from non-main threads (Qt is not thread-safe)
- Background threads (e.g.,
threading.Thread) are acceptable for non-GUI work (device polling, file monitoring)
The project uses two singleton patterns:
- Only the newer
metaclass=common.SingletonMetaclassshould be used @common.SingletonDecoratoris legacy and is not to be used anymore
- Use
Connectionsfor QML-to-Python signal connections when data binding cannot be used - Use
onSignalNamefor Python-to-QML property changes - QML model classes often inherit from
QtCore.QAbstractListModeland implementrowCount(),data(),roleNames()
Each plugin under action_plugins/<name>/ defines:
AbstractActionDatasubclass — XML serialization, validity, metadata (tag,name,icon,version,input_types,functor)AbstractFunctorsubclass — actual runtime behaviorActionModel— QML UI model, enabling modifying the data via the UI
Required overrides on AbstractActionData: _from_xml(), _to_xml(), is_valid(), _valid_selectors(), _get_container(), _handle_behavior_change().
Example:
from gremlin.base_classes import AbstractActionData, AbstractFunctor
from typing import override
class SpecialActionData(AbstractActionData):
tag = "special-action"
name = "Special Action"
icon = "f123"
version = 1
functor = SpecialActionFunctor
# Implement functions mandated by the base class.- Tests go in
test/unit/,test/integration, ortest/action_interaction/depending on use case- Self-contained unit tests are placed in
test/unit - Simulated action interaction tests are placed in
test/action_interaction - Full end to end system tests are placed in
test/integration
- Self-contained unit tests are placed in
- Function tests for action plugins are placed in
test/action_interaction - Tests in
test/action_interactionhave access to ajgbotfixture (`test/action_interaction/conftest.py:JoystickGremlinBot) which is similar to the Qt pytest fixture - Use pytest fixtures from
test/conftest.pyandtest/unit/conftest.py - Use
pytest.raises()for exception testing
Example:
from test.unit.conftest import get_fake_device_guid
def test_something(xml_dir: pathlib.Path):
p = Profile()
p.from_xml(str(xml_dir / "profile_simple.xml"))
assert len(p.inputs) == 1Use type aliases from gremlin.ui.type_aliases for Qt-specific types:
import gremlin.ui.type_aliases as ta- Use docstrings for classes and complex functions
- Keep docstrings concise; describe purpose and parameters
- Do not add unnecessary inline comments
- Use
dataclassesfor simple data containers - Use
ElementTreefor XML parsing/creation - Use
pathlib.Pathfor file paths
- Pylance may show
Import "PySide6" could not be resolved- this is a project-wide issue, not caused by your changes - Some
AbstractActionDataattribute unknowns may appear - also project-wide
Before considering a task complete, run:
poetry run ruff check .
poetry run pyright
poetry run pytest