ds4-mapper 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- ds4_mapper-0.1.0.dist-info/METADATA +167 -0
- ds4_mapper-0.1.0.dist-info/RECORD +13 -0
- ds4_mapper-0.1.0.dist-info/WHEEL +5 -0
- ds4_mapper-0.1.0.dist-info/entry_points.txt +2 -0
- ds4_mapper-0.1.0.dist-info/licenses/LICENSE +21 -0
- ds4_mapper-0.1.0.dist-info/top_level.txt +1 -0
- ds4mapper/__init__.py +0 -0
- ds4mapper/__main__.py +84 -0
- ds4mapper/cli.py +794 -0
- ds4mapper/keys.py +23 -0
- ds4mapper/mapper.py +143 -0
- ds4mapper/profiles/default.toml +26 -0
- ds4mapper/profiles.py +76 -0
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: ds4-mapper
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Map a DualShock 4 controller to keyboard inputs with a live TUI
|
|
5
|
+
Author: Jared Mahan
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/jaredmahan/ds4-mapper
|
|
8
|
+
Project-URL: Repository, https://github.com/jaredmahan/ds4-mapper
|
|
9
|
+
Project-URL: Issues, https://github.com/jaredmahan/ds4-mapper/issues
|
|
10
|
+
Keywords: ds4,dualshock4,controller,keyboard,mapping,gamepad,pygame
|
|
11
|
+
Classifier: Development Status :: 3 - Alpha
|
|
12
|
+
Classifier: Environment :: Console
|
|
13
|
+
Classifier: Intended Audience :: End Users/Desktop
|
|
14
|
+
Classifier: Operating System :: MacOS
|
|
15
|
+
Classifier: Operating System :: POSIX :: Linux
|
|
16
|
+
Classifier: Programming Language :: Python :: 3
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
19
|
+
Classifier: Topic :: Games/Entertainment
|
|
20
|
+
Classifier: Topic :: Utilities
|
|
21
|
+
Requires-Python: >=3.11
|
|
22
|
+
Description-Content-Type: text/markdown
|
|
23
|
+
License-File: LICENSE
|
|
24
|
+
Requires-Dist: pygame-ce
|
|
25
|
+
Requires-Dist: pynput
|
|
26
|
+
Requires-Dist: rich
|
|
27
|
+
Requires-Dist: textual
|
|
28
|
+
Provides-Extra: dev
|
|
29
|
+
Requires-Dist: pytest; extra == "dev"
|
|
30
|
+
Requires-Dist: ruff; extra == "dev"
|
|
31
|
+
Dynamic: license-file
|
|
32
|
+
|
|
33
|
+
# DS4 Mapper
|
|
34
|
+
|
|
35
|
+
Maps a DualShock 4 controller to keyboard inputs system-wide, so you can play browser games with a controller. Profiles are hot-swappable at runtime without restarting.
|
|
36
|
+
|
|
37
|
+
## Requirements
|
|
38
|
+
|
|
39
|
+
- Python 3.11+
|
|
40
|
+
- A DualShock 4 connected via Bluetooth
|
|
41
|
+
|
|
42
|
+
**macOS:** Grant Accessibility permission to your terminal in **System Settings → Privacy & Security → Accessibility** (required for keyboard injection).
|
|
43
|
+
|
|
44
|
+
## Install
|
|
45
|
+
|
|
46
|
+
From PyPI:
|
|
47
|
+
|
|
48
|
+
```bash
|
|
49
|
+
pip install ds4-mapper
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
Or from source (development mode):
|
|
53
|
+
|
|
54
|
+
```bash
|
|
55
|
+
git clone https://github.com/jaredmahan/ds4-mapper.git
|
|
56
|
+
cd ds4-mapper
|
|
57
|
+
pip install -e ".[dev]"
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
## Usage
|
|
61
|
+
|
|
62
|
+
Connect your DS4 via Bluetooth, then run:
|
|
63
|
+
|
|
64
|
+
```bash
|
|
65
|
+
ds4-mapper
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
Or without installing:
|
|
69
|
+
|
|
70
|
+
```bash
|
|
71
|
+
python -m ds4mapper
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
### Options
|
|
75
|
+
|
|
76
|
+
```
|
|
77
|
+
--profile NAME Profile to load on startup (default: default)
|
|
78
|
+
--discover Print raw button/axis indices — useful for building new profiles
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
### Discover Mode
|
|
82
|
+
|
|
83
|
+
If a button isn't registering, use discover mode to see the raw event index:
|
|
84
|
+
|
|
85
|
+
```bash
|
|
86
|
+
ds4-mapper --discover
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
## CLI Commands
|
|
90
|
+
|
|
91
|
+
Once the mapper is running, type commands at the `>` prompt:
|
|
92
|
+
|
|
93
|
+
| Command | Effect |
|
|
94
|
+
|---------|--------|
|
|
95
|
+
| `list` | Show available profiles |
|
|
96
|
+
| `switch <name>` | Load a profile (Tab-completes) |
|
|
97
|
+
| `current` | Show the active profile name |
|
|
98
|
+
| `reload` | Reload the current profile from disk |
|
|
99
|
+
| `view [<name>]` | Show button/axis/trigger mappings for a profile |
|
|
100
|
+
| `edit [<name>]` | Open the profile editor for an existing profile |
|
|
101
|
+
| `new <name>` | Create a new profile in the editor |
|
|
102
|
+
| `help` | Show command list |
|
|
103
|
+
| `quit` / `exit` | Stop |
|
|
104
|
+
|
|
105
|
+
## Profile Editor
|
|
106
|
+
|
|
107
|
+
`edit` and `new` open an interactive editor screen where you can map controller inputs to keyboard keys:
|
|
108
|
+
|
|
109
|
+
1. **Press a DS4 button, stick, or trigger** — the editor enters *waiting* mode and prompts for a key.
|
|
110
|
+
2. **Press the keyboard key** you want it mapped to — the mapping is recorded.
|
|
111
|
+
3. Repeat for each input you want to map.
|
|
112
|
+
|
|
113
|
+
You can also type commands in the editor's input field:
|
|
114
|
+
|
|
115
|
+
| Command | Effect |
|
|
116
|
+
|---------|--------|
|
|
117
|
+
| `save [stem]` | Save to disk (optional custom filename) |
|
|
118
|
+
| `delete <input>` | Remove a mapping (e.g. `delete Cross`) |
|
|
119
|
+
| `name <text>` | Set the profile display name |
|
|
120
|
+
| `desc <text>` | Set the profile description |
|
|
121
|
+
| `cancel` | Discard changes and return to the main screen |
|
|
122
|
+
|
|
123
|
+
Keyboard injection is suspended while the editor is open so controller inputs don't fire game actions.
|
|
124
|
+
|
|
125
|
+
## Profiles
|
|
126
|
+
|
|
127
|
+
Profiles live in `ds4mapper/profiles/`. Each is a TOML file:
|
|
128
|
+
|
|
129
|
+
```toml
|
|
130
|
+
name = "default"
|
|
131
|
+
description = "Standard browser game layout"
|
|
132
|
+
|
|
133
|
+
[buttons]
|
|
134
|
+
0 = "x" # Cross
|
|
135
|
+
1 = "z" # Circle
|
|
136
|
+
2 = "s" # Square
|
|
137
|
+
3 = "a" # Triangle
|
|
138
|
+
4 = "v" # Share
|
|
139
|
+
6 = "enter" # Options / START
|
|
140
|
+
9 = "q" # L1
|
|
141
|
+
10 = "e" # R1
|
|
142
|
+
11 = "up"
|
|
143
|
+
12 = "down"
|
|
144
|
+
13 = "left"
|
|
145
|
+
14 = "right"
|
|
146
|
+
|
|
147
|
+
[axes]
|
|
148
|
+
0 = ["f", "h"] # Left Stick X (neg, pos)
|
|
149
|
+
1 = ["t", "g"] # Left Stick Y
|
|
150
|
+
2 = ["j", "l"] # Right Stick X
|
|
151
|
+
3 = ["i", "k"] # Right Stick Y
|
|
152
|
+
|
|
153
|
+
[triggers]
|
|
154
|
+
4 = "tab" # L2
|
|
155
|
+
5 = "r" # R2
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
Special key names: `enter`, `tab`, `up`, `down`, `left`, `right`, `space`, `esc`, `shift`, `ctrl`, `alt`. Single characters are used directly.
|
|
159
|
+
|
|
160
|
+
Profiles can be created or edited from the CLI with `new` and `edit`, or by copying and editing TOML files directly.
|
|
161
|
+
|
|
162
|
+
## Publishing to PyPI
|
|
163
|
+
|
|
164
|
+
This project uses [Trusted Publishing](https://docs.pypi.org/trusted-publishers/) via GitHub Actions. To release a new version:
|
|
165
|
+
|
|
166
|
+
1. Bump `version` in `pyproject.toml`.
|
|
167
|
+
2. Create a GitHub release — the `publish.yml` workflow builds and uploads the distribution automatically.
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
ds4_mapper-0.1.0.dist-info/licenses/LICENSE,sha256=9sOawI-CC-_5Yi9y-E4nOOsnbludhx6BWD5grO7r2ws,1068
|
|
2
|
+
ds4mapper/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
3
|
+
ds4mapper/__main__.py,sha256=OZSOY_GeC5ghBeyXrvzFuj8hoRCDqG0SscJXj6FVQF4,2653
|
|
4
|
+
ds4mapper/cli.py,sha256=Uoj5VMLacPp2M0QY6ZHLglfQ97ooAuI_kWcR8Acr_eI,28586
|
|
5
|
+
ds4mapper/keys.py,sha256=CuyEjv6iEVQzjeEwbiOcNNf1j_8vm9TLBRmlxJQ8cmg,488
|
|
6
|
+
ds4mapper/mapper.py,sha256=SQkj2g32uD5pRzTgdpTSsvTRTSh2Q69B3oI8neyyB9A,5622
|
|
7
|
+
ds4mapper/profiles.py,sha256=Zx7E1IQ4aHbRLf35tkKC2VqtEdGPeoV5TlMP6CVNH3s,2393
|
|
8
|
+
ds4mapper/profiles/default.toml,sha256=iWqkwfM-RTFZ-d74SM1yHLJ4YedfI_ejq7mQRsqW9Ik,294
|
|
9
|
+
ds4_mapper-0.1.0.dist-info/METADATA,sha256=uBdVL9Xo3e9vEOXO4ku4fnSxj9-CaZZq8NGGJHS8O4o,4766
|
|
10
|
+
ds4_mapper-0.1.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
|
|
11
|
+
ds4_mapper-0.1.0.dist-info/entry_points.txt,sha256=T8V-rGat-NrzIylA3RBkKxw2MJYHE6x2-s5g5Oob8BE,55
|
|
12
|
+
ds4_mapper-0.1.0.dist-info/top_level.txt,sha256=7fAt7tmgZ4c2eLSeLDijwnipdViWX6VGOT-JQqFUdCQ,10
|
|
13
|
+
ds4_mapper-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 Jared Mahan
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
ds4mapper
|
ds4mapper/__init__.py
ADDED
|
File without changes
|
ds4mapper/__main__.py
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import argparse
|
|
2
|
+
import os
|
|
3
|
+
import sys
|
|
4
|
+
import time
|
|
5
|
+
|
|
6
|
+
os.environ["SDL_JOYSTICK_ALLOW_BACKGROUND_EVENTS"] = "1"
|
|
7
|
+
|
|
8
|
+
import pygame
|
|
9
|
+
|
|
10
|
+
from ds4mapper.cli import run
|
|
11
|
+
from ds4mapper.profiles import load_profile
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _init_pygame() -> pygame.joystick.Joystick:
|
|
15
|
+
pygame.init()
|
|
16
|
+
pygame.display.set_caption("DS4 Mapper")
|
|
17
|
+
pygame.display.set_mode((220, 40))
|
|
18
|
+
pygame.joystick.init()
|
|
19
|
+
|
|
20
|
+
if pygame.joystick.get_count() == 0:
|
|
21
|
+
timeout = 30
|
|
22
|
+
deadline = time.monotonic() + timeout
|
|
23
|
+
while pygame.joystick.get_count() == 0:
|
|
24
|
+
remaining = int(deadline - time.monotonic())
|
|
25
|
+
if remaining <= 0:
|
|
26
|
+
print("\rNo controller found. Connect the DS4 via Bluetooth and try again.")
|
|
27
|
+
sys.exit(1)
|
|
28
|
+
print(
|
|
29
|
+
f"\rNo controller detected. Waiting for DS4... {remaining}s ", end="", flush=True
|
|
30
|
+
)
|
|
31
|
+
pygame.event.pump()
|
|
32
|
+
time.sleep(1)
|
|
33
|
+
print() # newline after countdown
|
|
34
|
+
|
|
35
|
+
joy = pygame.joystick.Joystick(0)
|
|
36
|
+
joy.init()
|
|
37
|
+
print(f"Connected: {joy.get_name()}")
|
|
38
|
+
return joy
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _discover(joy: pygame.joystick.Joystick) -> None:
|
|
42
|
+
print("\nDiscover mode — press every button and move every stick/trigger.")
|
|
43
|
+
print("Ctrl-C to quit.\n")
|
|
44
|
+
while True:
|
|
45
|
+
for event in pygame.event.get():
|
|
46
|
+
if event.type == pygame.QUIT:
|
|
47
|
+
return
|
|
48
|
+
elif event.type == pygame.JOYBUTTONDOWN:
|
|
49
|
+
print(f" BUTTON DOWN index={event.button}")
|
|
50
|
+
elif event.type == pygame.JOYBUTTONUP:
|
|
51
|
+
print(f" BUTTON UP index={event.button}")
|
|
52
|
+
elif event.type == pygame.JOYHATMOTION:
|
|
53
|
+
print(f" HAT value={event.value}")
|
|
54
|
+
elif event.type == pygame.JOYAXISMOTION:
|
|
55
|
+
print(f" AXIS index={event.axis} value={event.value:.3f}")
|
|
56
|
+
time.sleep(0.016)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def main() -> None:
|
|
60
|
+
parser = argparse.ArgumentParser(description="DS4 → Keyboard Mapper")
|
|
61
|
+
parser.add_argument(
|
|
62
|
+
"--discover", action="store_true", help="Print raw events to verify button/axis indices"
|
|
63
|
+
)
|
|
64
|
+
parser.add_argument(
|
|
65
|
+
"--profile", default="default", help="Profile name to load on startup (default: default)"
|
|
66
|
+
)
|
|
67
|
+
args = parser.parse_args()
|
|
68
|
+
|
|
69
|
+
joy = _init_pygame()
|
|
70
|
+
try:
|
|
71
|
+
if args.discover:
|
|
72
|
+
print(f"Discover mode — {joy.get_name()}")
|
|
73
|
+
_discover(joy)
|
|
74
|
+
else:
|
|
75
|
+
profile = load_profile(args.profile)
|
|
76
|
+
run(joy, profile, args.profile)
|
|
77
|
+
except KeyboardInterrupt:
|
|
78
|
+
print("\nStopped.")
|
|
79
|
+
finally:
|
|
80
|
+
pygame.quit()
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
if __name__ == "__main__":
|
|
84
|
+
main()
|
ds4mapper/cli.py
ADDED
|
@@ -0,0 +1,794 @@
|
|
|
1
|
+
import copy
|
|
2
|
+
import threading
|
|
3
|
+
from collections import deque
|
|
4
|
+
|
|
5
|
+
import pygame
|
|
6
|
+
from rich.align import Align
|
|
7
|
+
from rich.panel import Panel
|
|
8
|
+
from rich.text import Text
|
|
9
|
+
from textual import events
|
|
10
|
+
from textual.app import App, ComposeResult
|
|
11
|
+
from textual.containers import Horizontal
|
|
12
|
+
from textual.screen import Screen
|
|
13
|
+
from textual.widgets import Input, RichLog, Static
|
|
14
|
+
|
|
15
|
+
from ds4mapper.keys import resolve
|
|
16
|
+
from ds4mapper.mapper import DEADZONE, TRIGGER_DEADZONE, MapperThread
|
|
17
|
+
from ds4mapper.profiles import PROFILES_DIR, Profile, list_profiles, load_profile, save_profile
|
|
18
|
+
|
|
19
|
+
# Each row is [(style, text), ...]; all must render to exactly 61 chars.
|
|
20
|
+
_ART_LINES: list[list[tuple[str, str]]] = [
|
|
21
|
+
[("cyan", " ╭──╮ ╭──╮ ")],
|
|
22
|
+
[
|
|
23
|
+
("dim", " L2 ──────"),
|
|
24
|
+
("cyan", "┤ ├"),
|
|
25
|
+
("dim", "──── L1 R1 ────"),
|
|
26
|
+
("cyan", "┤ ├"),
|
|
27
|
+
("dim", "──── R2 "),
|
|
28
|
+
],
|
|
29
|
+
[("cyan", " ╭────────┴──┴─────────────────────────────────┴──┴──────╮ ")],
|
|
30
|
+
[
|
|
31
|
+
("cyan", " │ "),
|
|
32
|
+
("white", " ↑ "),
|
|
33
|
+
("cyan", " "),
|
|
34
|
+
("dim", "╔═══════════╗"),
|
|
35
|
+
("cyan", " "),
|
|
36
|
+
("yellow", " △ "),
|
|
37
|
+
("cyan", " │ "),
|
|
38
|
+
],
|
|
39
|
+
[
|
|
40
|
+
("cyan", " │ "),
|
|
41
|
+
("white", " ◄ + ► "),
|
|
42
|
+
("cyan", " "),
|
|
43
|
+
("dim", "shr"),
|
|
44
|
+
("cyan", " "),
|
|
45
|
+
("dim", "║ TOUCHPAD ║"),
|
|
46
|
+
("cyan", " "),
|
|
47
|
+
("dim", "opt"),
|
|
48
|
+
("cyan", " "),
|
|
49
|
+
("cyan", " "),
|
|
50
|
+
("magenta", "□"),
|
|
51
|
+
("cyan", " "),
|
|
52
|
+
("red", "○"),
|
|
53
|
+
("cyan", " "),
|
|
54
|
+
("cyan", " │ "),
|
|
55
|
+
],
|
|
56
|
+
[
|
|
57
|
+
("cyan", " │ "),
|
|
58
|
+
("white", " ↓ "),
|
|
59
|
+
("cyan", " "),
|
|
60
|
+
("dim", "╚═══════════╝"),
|
|
61
|
+
("cyan", " "),
|
|
62
|
+
("blue", " ✕ "),
|
|
63
|
+
("cyan", " │ "),
|
|
64
|
+
],
|
|
65
|
+
[
|
|
66
|
+
("cyan", " │ "),
|
|
67
|
+
("cyan", " "),
|
|
68
|
+
("cyan", " │ "),
|
|
69
|
+
],
|
|
70
|
+
[
|
|
71
|
+
("cyan", " │ "),
|
|
72
|
+
("dim", " ╭───╮"),
|
|
73
|
+
("cyan", " ⊛ "),
|
|
74
|
+
("dim", "╭───╮ "),
|
|
75
|
+
("cyan", " │ "),
|
|
76
|
+
],
|
|
77
|
+
[
|
|
78
|
+
("cyan", " │ "),
|
|
79
|
+
("dim", " │ L │"),
|
|
80
|
+
("cyan", " "),
|
|
81
|
+
("dim", "│ R │ "),
|
|
82
|
+
("cyan", " │ "),
|
|
83
|
+
],
|
|
84
|
+
[
|
|
85
|
+
("cyan", " │ "),
|
|
86
|
+
("dim", " ╰───╯"),
|
|
87
|
+
("cyan", " "),
|
|
88
|
+
("dim", "╰───╯ "),
|
|
89
|
+
("cyan", " │ "),
|
|
90
|
+
],
|
|
91
|
+
[("cyan", " ╰──╮ ╭──╯ ")],
|
|
92
|
+
[("cyan", " ╰─────────────────────────────────────────────────╯ ")],
|
|
93
|
+
]
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
_SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]
|
|
97
|
+
|
|
98
|
+
_TRIGGER_AXES: frozenset[int] = frozenset({4, 5})
|
|
99
|
+
|
|
100
|
+
_TEXTUAL_SPECIAL: dict[str, str] = {
|
|
101
|
+
"enter": "enter",
|
|
102
|
+
"tab": "tab",
|
|
103
|
+
"space": "space",
|
|
104
|
+
"up": "up",
|
|
105
|
+
"down": "down",
|
|
106
|
+
"left": "left",
|
|
107
|
+
"right": "right",
|
|
108
|
+
"escape": "esc",
|
|
109
|
+
"shift": "shift",
|
|
110
|
+
"ctrl": "ctrl",
|
|
111
|
+
"alt": "alt",
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _textual_key_to_profile(event: events.Key) -> str | None:
|
|
116
|
+
"""Convert a Textual Key event to a profile key string, or None if not capturable."""
|
|
117
|
+
if event.character and len(event.character) == 1 and event.character.isprintable():
|
|
118
|
+
return event.character
|
|
119
|
+
return _TEXTUAL_SPECIAL.get(event.key)
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
class SplashScreen(Screen[None]):
|
|
123
|
+
DEFAULT_CSS = """
|
|
124
|
+
SplashScreen {
|
|
125
|
+
align: center middle;
|
|
126
|
+
}
|
|
127
|
+
SplashScreen #hint {
|
|
128
|
+
margin-top: 1;
|
|
129
|
+
text-align: center;
|
|
130
|
+
}
|
|
131
|
+
"""
|
|
132
|
+
|
|
133
|
+
_frame: int = 0
|
|
134
|
+
|
|
135
|
+
def __init__(self, controller_name: str, profile_name: str) -> None:
|
|
136
|
+
super().__init__()
|
|
137
|
+
self._controller_name = controller_name
|
|
138
|
+
self._profile_name = profile_name
|
|
139
|
+
|
|
140
|
+
def _build_panel(self) -> Panel:
|
|
141
|
+
body = Text(justify="center", no_wrap=True)
|
|
142
|
+
body.append("◆ DS4 MAPPER\n", style="bold cyan")
|
|
143
|
+
body.append("DualShock 4 → Keyboard\n\n", style="dim")
|
|
144
|
+
for segments in _ART_LINES:
|
|
145
|
+
for style, chunk in segments:
|
|
146
|
+
body.append(chunk, style=style)
|
|
147
|
+
body.append("\n")
|
|
148
|
+
body.append("\n")
|
|
149
|
+
body.append("Controller ", style="dim")
|
|
150
|
+
body.append(self._controller_name, style="cyan bold")
|
|
151
|
+
body.append(" Profile ", style="dim")
|
|
152
|
+
body.append(self._profile_name, style="green bold")
|
|
153
|
+
body.append("\n\n")
|
|
154
|
+
body.append("list", style="bold white")
|
|
155
|
+
body.append(" · ", style="dim")
|
|
156
|
+
body.append("switch <name>", style="bold white")
|
|
157
|
+
body.append(" · ", style="dim")
|
|
158
|
+
body.append("current", style="bold white")
|
|
159
|
+
body.append(" · ", style="dim")
|
|
160
|
+
body.append("reload", style="bold white")
|
|
161
|
+
body.append(" · ", style="dim")
|
|
162
|
+
body.append("quit", style="bold white")
|
|
163
|
+
return Panel(Align.center(body), border_style="cyan", padding=(0, 2))
|
|
164
|
+
|
|
165
|
+
def compose(self) -> ComposeResult:
|
|
166
|
+
yield Static(self._build_panel(), id="art")
|
|
167
|
+
yield Static("", id="hint")
|
|
168
|
+
|
|
169
|
+
def _tick_spinner(self) -> None:
|
|
170
|
+
frame = _SPINNER_FRAMES[self._frame % len(_SPINNER_FRAMES)]
|
|
171
|
+
self._frame += 1
|
|
172
|
+
self.query_one("#hint", Static).update(f"[cyan]{frame}[/] [dim]Loading…[/]")
|
|
173
|
+
|
|
174
|
+
def _do_dismiss(self) -> None:
|
|
175
|
+
self.dismiss()
|
|
176
|
+
|
|
177
|
+
def on_mount(self) -> None:
|
|
178
|
+
self.set_timer(3.0, self._do_dismiss)
|
|
179
|
+
self.set_interval(0.08, self._tick_spinner)
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
class EditScreen(Screen[None]):
|
|
183
|
+
DEFAULT_CSS = """
|
|
184
|
+
EditScreen { layout: vertical; }
|
|
185
|
+
#edit-header {
|
|
186
|
+
height: 3;
|
|
187
|
+
border: solid cyan;
|
|
188
|
+
padding: 0 1;
|
|
189
|
+
content-align: left middle;
|
|
190
|
+
}
|
|
191
|
+
#edit-log { border: solid grey; }
|
|
192
|
+
#edit-status {
|
|
193
|
+
height: 3;
|
|
194
|
+
border: solid yellow;
|
|
195
|
+
padding: 0 1;
|
|
196
|
+
content-align: left middle;
|
|
197
|
+
}
|
|
198
|
+
Input { dock: bottom; }
|
|
199
|
+
"""
|
|
200
|
+
|
|
201
|
+
_IDLE = "idle"
|
|
202
|
+
_WAITING = "waiting"
|
|
203
|
+
|
|
204
|
+
def __init__(self, profile: Profile, stem: str) -> None:
|
|
205
|
+
super().__init__()
|
|
206
|
+
self._profile = copy.deepcopy(profile)
|
|
207
|
+
self._stem = stem
|
|
208
|
+
self._state = self._IDLE
|
|
209
|
+
self._pending: str | None = None
|
|
210
|
+
self._dirty = False
|
|
211
|
+
|
|
212
|
+
def compose(self) -> ComposeResult:
|
|
213
|
+
yield Static("", id="edit-header")
|
|
214
|
+
yield RichLog(id="edit-log", markup=True, highlight=True)
|
|
215
|
+
yield Static("", id="edit-status")
|
|
216
|
+
yield Input(
|
|
217
|
+
placeholder="save [name] · delete <input> · name <text> · desc <text> · cancel",
|
|
218
|
+
id="edit-cmd",
|
|
219
|
+
)
|
|
220
|
+
|
|
221
|
+
def on_mount(self) -> None:
|
|
222
|
+
self._refresh_header()
|
|
223
|
+
self._refresh_log()
|
|
224
|
+
self._refresh_status()
|
|
225
|
+
self.query_one("#edit-cmd", Input).focus()
|
|
226
|
+
|
|
227
|
+
def _refresh_header(self) -> None:
|
|
228
|
+
marker = " [yellow bold]*[/]" if self._dirty else ""
|
|
229
|
+
desc = f" [dim]{self._profile.description}[/]" if self._profile.description else ""
|
|
230
|
+
self.query_one("#edit-header", Static).update(
|
|
231
|
+
f"[dim]Editing:[/] [bold cyan]{self._profile.name}[/]{desc}{marker}"
|
|
232
|
+
f" [dim]│[/] [dim]{self._stem}.toml[/]"
|
|
233
|
+
)
|
|
234
|
+
|
|
235
|
+
def _refresh_log(self) -> None:
|
|
236
|
+
log = self.query_one("#edit-log", RichLog)
|
|
237
|
+
log.clear()
|
|
238
|
+
for line in _profile_lines(self._profile):
|
|
239
|
+
log.write(line)
|
|
240
|
+
if not (self._profile.buttons or self._profile.axes or self._profile.triggers):
|
|
241
|
+
log.write("[dim](no mappings yet — press a DS4 button, stick, or trigger)[/]")
|
|
242
|
+
|
|
243
|
+
def _refresh_status(self) -> None:
|
|
244
|
+
status = self.query_one("#edit-status", Static)
|
|
245
|
+
if self._state == self._IDLE:
|
|
246
|
+
status.update("[dim]Press a DS4 button, stick, or trigger to assign a key.[/]")
|
|
247
|
+
else:
|
|
248
|
+
ds4_name = _event_label(self._pending)
|
|
249
|
+
status.update(
|
|
250
|
+
f"[bold cyan]{ds4_name}[/] → "
|
|
251
|
+
f"[dim]press the keyboard key to assign[/] [dim](Esc = cancel)[/]"
|
|
252
|
+
)
|
|
253
|
+
|
|
254
|
+
def receive_ds4(self, label: str) -> None:
|
|
255
|
+
if self._state != self._IDLE:
|
|
256
|
+
return
|
|
257
|
+
self._pending = label
|
|
258
|
+
self._state = self._WAITING
|
|
259
|
+
self._refresh_status()
|
|
260
|
+
self.query_one("#edit-cmd", Input).blur()
|
|
261
|
+
|
|
262
|
+
def on_key(self, event: events.Key) -> None:
|
|
263
|
+
if self._state != self._WAITING:
|
|
264
|
+
return
|
|
265
|
+
if event.key == "escape":
|
|
266
|
+
self._state = self._IDLE
|
|
267
|
+
self._pending = None
|
|
268
|
+
self._refresh_status()
|
|
269
|
+
self.query_one("#edit-cmd", Input).focus()
|
|
270
|
+
event.stop()
|
|
271
|
+
return
|
|
272
|
+
key_str = _textual_key_to_profile(event)
|
|
273
|
+
if key_str is None:
|
|
274
|
+
return
|
|
275
|
+
event.stop()
|
|
276
|
+
try:
|
|
277
|
+
self._apply_mapping(self._pending, key_str)
|
|
278
|
+
except ValueError as exc:
|
|
279
|
+
self.query_one("#edit-log", RichLog).write(f"[red]Cannot map:[/] {exc}")
|
|
280
|
+
self._pending = None
|
|
281
|
+
self._state = self._IDLE
|
|
282
|
+
self._dirty = True
|
|
283
|
+
self._refresh_header()
|
|
284
|
+
self._refresh_log()
|
|
285
|
+
self._refresh_status()
|
|
286
|
+
self.query_one("#edit-cmd", Input).focus()
|
|
287
|
+
|
|
288
|
+
def _apply_mapping(self, ds4_label: str, key_str: str) -> None:
|
|
289
|
+
resolved = resolve(key_str)
|
|
290
|
+
parts = ds4_label.split(":")
|
|
291
|
+
kind, idx = parts[0], int(parts[1])
|
|
292
|
+
if kind == "button":
|
|
293
|
+
self._profile.buttons[idx] = resolved
|
|
294
|
+
elif kind == "trigger":
|
|
295
|
+
self._profile.triggers[idx] = resolved
|
|
296
|
+
elif kind == "axis":
|
|
297
|
+
direction = parts[2]
|
|
298
|
+
if idx in self._profile.axes:
|
|
299
|
+
neg, pos = self._profile.axes[idx]
|
|
300
|
+
self._profile.axes[idx] = (resolved, pos) if direction == "-" else (neg, resolved)
|
|
301
|
+
else:
|
|
302
|
+
self._profile.axes[idx] = (resolved, "?") if direction == "-" else ("?", resolved)
|
|
303
|
+
|
|
304
|
+
def on_input_submitted(self, event: Input.Submitted) -> None:
|
|
305
|
+
line = event.value.strip()
|
|
306
|
+
log = self.query_one("#edit-log", RichLog)
|
|
307
|
+
event.input.clear()
|
|
308
|
+
if not line:
|
|
309
|
+
return
|
|
310
|
+
parts = line.split()
|
|
311
|
+
cmd = parts[0].lower()
|
|
312
|
+
|
|
313
|
+
if cmd in ("cancel", "quit", "exit"):
|
|
314
|
+
self.dismiss()
|
|
315
|
+
elif cmd == "save":
|
|
316
|
+
stem = parts[1] if len(parts) >= 2 else self._stem
|
|
317
|
+
partial = [
|
|
318
|
+
idx
|
|
319
|
+
for idx, (neg, pos) in self._profile.axes.items()
|
|
320
|
+
if "?" in (_key_label(neg), _key_label(pos))
|
|
321
|
+
]
|
|
322
|
+
if partial:
|
|
323
|
+
log.write(
|
|
324
|
+
f"[yellow]Note:[/] {len(partial)} axis mapping(s) only have one direction set"
|
|
325
|
+
" — assign both directions or they will be skipped on save."
|
|
326
|
+
)
|
|
327
|
+
try:
|
|
328
|
+
save_profile(self._profile, stem)
|
|
329
|
+
except Exception as exc: # noqa: BLE001
|
|
330
|
+
log.write(f"[red]Error:[/] {exc}")
|
|
331
|
+
return
|
|
332
|
+
self._stem = stem
|
|
333
|
+
self._dirty = False
|
|
334
|
+
self._refresh_header()
|
|
335
|
+
log.write(f"[green]Saved[/] → [cyan]{stem}.toml[/]")
|
|
336
|
+
elif cmd == "delete":
|
|
337
|
+
if len(parts) < 2:
|
|
338
|
+
log.write("[red]usage:[/] delete button:<n> · axis:<n> · trigger:<n>")
|
|
339
|
+
return
|
|
340
|
+
self._delete_mapping(parts[1], log)
|
|
341
|
+
elif cmd == "name":
|
|
342
|
+
if len(parts) < 2:
|
|
343
|
+
log.write("[red]usage:[/] name <new-profile-name>")
|
|
344
|
+
return
|
|
345
|
+
self._profile.name = " ".join(parts[1:])
|
|
346
|
+
self._dirty = True
|
|
347
|
+
self._refresh_header()
|
|
348
|
+
elif cmd == "desc":
|
|
349
|
+
self._profile.description = " ".join(parts[1:]) if len(parts) >= 2 else ""
|
|
350
|
+
self._dirty = True
|
|
351
|
+
self._refresh_header()
|
|
352
|
+
else:
|
|
353
|
+
log.write(
|
|
354
|
+
f"Unknown: [bold]{cmd!r}[/] — "
|
|
355
|
+
"[bold]save[/] [bold]delete[/] [bold]name[/] [bold]desc[/] [bold]cancel[/]"
|
|
356
|
+
)
|
|
357
|
+
|
|
358
|
+
def _delete_mapping(self, label: str, log: RichLog) -> None:
|
|
359
|
+
parts = label.split(":")
|
|
360
|
+
if len(parts) < 2:
|
|
361
|
+
log.write(f"[red]Invalid:[/] {label!r} — use button:<n>, axis:<n>, trigger:<n>")
|
|
362
|
+
return
|
|
363
|
+
kind = parts[0]
|
|
364
|
+
try:
|
|
365
|
+
idx = int(parts[1])
|
|
366
|
+
except ValueError:
|
|
367
|
+
log.write(f"[red]Invalid index:[/] {parts[1]!r}")
|
|
368
|
+
return
|
|
369
|
+
removed = False
|
|
370
|
+
if kind == "button":
|
|
371
|
+
removed = self._profile.buttons.pop(idx, None) is not None
|
|
372
|
+
elif kind == "trigger":
|
|
373
|
+
removed = self._profile.triggers.pop(idx, None) is not None
|
|
374
|
+
elif kind == "axis":
|
|
375
|
+
removed = self._profile.axes.pop(idx, None) is not None
|
|
376
|
+
else:
|
|
377
|
+
log.write(f"[red]Unknown type:[/] {kind!r} — use button, axis, or trigger")
|
|
378
|
+
return
|
|
379
|
+
if removed:
|
|
380
|
+
self._dirty = True
|
|
381
|
+
self._refresh_header()
|
|
382
|
+
self._refresh_log()
|
|
383
|
+
else:
|
|
384
|
+
log.write(f"[dim]Not mapped:[/] {kind} {idx}")
|
|
385
|
+
|
|
386
|
+
|
|
387
|
+
_RECENT_MAX = 5
|
|
388
|
+
|
|
389
|
+
_DS4_BUTTON_NAMES: dict[int, str] = {
|
|
390
|
+
0: "Cross ✕",
|
|
391
|
+
1: "Circle ○",
|
|
392
|
+
2: "Square □",
|
|
393
|
+
3: "Triangle △",
|
|
394
|
+
4: "L1",
|
|
395
|
+
5: "R1",
|
|
396
|
+
6: "L2",
|
|
397
|
+
7: "R2",
|
|
398
|
+
8: "Share",
|
|
399
|
+
9: "Options",
|
|
400
|
+
10: "L3",
|
|
401
|
+
11: "D-pad ↑",
|
|
402
|
+
12: "D-pad ↓",
|
|
403
|
+
13: "D-pad ←",
|
|
404
|
+
14: "D-pad →",
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
_DS4_AXIS_NAMES: dict[int, str] = {
|
|
408
|
+
0: "Left X",
|
|
409
|
+
1: "Left Y",
|
|
410
|
+
2: "Right X",
|
|
411
|
+
3: "Right Y",
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
_DS4_TRIGGER_NAMES: dict[int, str] = {
|
|
415
|
+
4: "L2",
|
|
416
|
+
5: "R2",
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
_SEP_RICH = " [dim]│[/] "
|
|
420
|
+
|
|
421
|
+
_BUTTON_GROUPS: list[tuple[str, list[int]]] = [
|
|
422
|
+
("Face", [0, 1, 2, 3]),
|
|
423
|
+
("Shoulder", [4, 5, 6, 7]),
|
|
424
|
+
("D-pad", [11, 12, 13, 14]),
|
|
425
|
+
("Other", [8, 9, 10]),
|
|
426
|
+
]
|
|
427
|
+
_GROUPED_BUTTON_INDICES: set[int] = {i for _, idxs in _BUTTON_GROUPS for i in idxs}
|
|
428
|
+
|
|
429
|
+
|
|
430
|
+
def _key_label(k: object) -> str:
|
|
431
|
+
return k if isinstance(k, str) else k.name # type: ignore[union-attr]
|
|
432
|
+
|
|
433
|
+
|
|
434
|
+
def _event_label(raw: str) -> str:
|
|
435
|
+
"""Convert a raw mapper label (e.g. 'button:0') to a human-readable DS4 name."""
|
|
436
|
+
parts = raw.split(":")
|
|
437
|
+
kind = parts[0]
|
|
438
|
+
if kind == "button" and len(parts) == 2:
|
|
439
|
+
idx = int(parts[1])
|
|
440
|
+
return _DS4_BUTTON_NAMES.get(idx, f"Button {idx}")
|
|
441
|
+
if kind == "axis" and len(parts) == 3:
|
|
442
|
+
idx = int(parts[1])
|
|
443
|
+
hw = _DS4_AXIS_NAMES.get(idx, f"Axis {idx}")
|
|
444
|
+
arrow = "→" if parts[2] == "+" else "←"
|
|
445
|
+
return f"{hw} {arrow}"
|
|
446
|
+
if kind == "trigger" and len(parts) == 2:
|
|
447
|
+
idx = int(parts[1])
|
|
448
|
+
return _DS4_TRIGGER_NAMES.get(idx, f"Trigger {idx}")
|
|
449
|
+
return raw
|
|
450
|
+
|
|
451
|
+
|
|
452
|
+
def _into_columns(rich_cells: list[str], plain_cells: list[str], cols: int = 3) -> list[str]:
|
|
453
|
+
"""Pack (rich, plain) cell pairs into rows of `cols`, filling top-to-bottom per column."""
|
|
454
|
+
if not rich_cells:
|
|
455
|
+
return []
|
|
456
|
+
n = len(rich_cells)
|
|
457
|
+
num_rows = (n + cols - 1) // cols
|
|
458
|
+
cell_w = max(len(p) for p in plain_cells)
|
|
459
|
+
rows = []
|
|
460
|
+
for r in range(num_rows):
|
|
461
|
+
col_rich, col_plain = [], []
|
|
462
|
+
for c in range(cols):
|
|
463
|
+
idx = c * num_rows + r
|
|
464
|
+
if idx < n:
|
|
465
|
+
col_rich.append(rich_cells[idx])
|
|
466
|
+
col_plain.append(plain_cells[idx])
|
|
467
|
+
row = ""
|
|
468
|
+
for j, (rich, plain) in enumerate(zip(col_rich, col_plain)):
|
|
469
|
+
if j == len(col_rich) - 1:
|
|
470
|
+
row += rich
|
|
471
|
+
else:
|
|
472
|
+
row += rich + " " * (cell_w - len(plain)) + _SEP_RICH
|
|
473
|
+
rows.append(row)
|
|
474
|
+
return rows
|
|
475
|
+
|
|
476
|
+
|
|
477
|
+
def _profile_lines(p: Profile) -> list[str]:
|
|
478
|
+
header = f"[bold cyan]{p.name}[/]"
|
|
479
|
+
if p.description:
|
|
480
|
+
header += f" [dim]{p.description}[/]"
|
|
481
|
+
lines = [header, ""]
|
|
482
|
+
|
|
483
|
+
if p.buttons:
|
|
484
|
+
lines.append("[dim]Buttons[/]")
|
|
485
|
+
# Sort by group order so column-major fill keeps each group in its own column.
|
|
486
|
+
order = [idx for _, idxs in _BUTTON_GROUPS for idx in idxs if idx in p.buttons]
|
|
487
|
+
order += sorted(i for i in p.buttons if i not in _GROUPED_BUTTON_INDICES)
|
|
488
|
+
rich_cells, plain_cells = [], []
|
|
489
|
+
for idx in order:
|
|
490
|
+
hw = _DS4_BUTTON_NAMES.get(idx, f"Button {idx}")
|
|
491
|
+
key = _key_label(p.buttons[idx])
|
|
492
|
+
plain_cells.append(f" {hw:<13}→ {key}")
|
|
493
|
+
rich_cells.append(f" [dim]{hw:<13}[/]→ [bold]{key}[/]")
|
|
494
|
+
lines.extend(_into_columns(rich_cells, plain_cells))
|
|
495
|
+
lines.append("")
|
|
496
|
+
|
|
497
|
+
if p.axes:
|
|
498
|
+
lines.append("[dim]Axes[/]")
|
|
499
|
+
rich_cells, plain_cells = [], []
|
|
500
|
+
for idx in sorted(p.axes):
|
|
501
|
+
hw = _DS4_AXIS_NAMES.get(idx, f"Axis {idx}")
|
|
502
|
+
neg, pos = _key_label(p.axes[idx][0]), _key_label(p.axes[idx][1])
|
|
503
|
+
plain_cells.append(f" {hw:<9}{neg} ←·→ {pos}")
|
|
504
|
+
rich_cells.append(f" [dim]{hw:<9}[/][bold]{neg}[/] [dim]←·→[/] [bold]{pos}[/]")
|
|
505
|
+
lines.extend(_into_columns(rich_cells, plain_cells))
|
|
506
|
+
lines.append("")
|
|
507
|
+
|
|
508
|
+
if p.triggers:
|
|
509
|
+
lines.append("[dim]Triggers[/]")
|
|
510
|
+
rich_cells, plain_cells = [], []
|
|
511
|
+
for idx in sorted(p.triggers):
|
|
512
|
+
hw = _DS4_TRIGGER_NAMES.get(idx, f"Trigger {idx}")
|
|
513
|
+
key = _key_label(p.triggers[idx])
|
|
514
|
+
plain_cells.append(f" {hw:<5}→ {key}")
|
|
515
|
+
rich_cells.append(f" [dim]{hw:<5}[/]→ [bold]{key}[/]")
|
|
516
|
+
lines.extend(_into_columns(rich_cells, plain_cells))
|
|
517
|
+
|
|
518
|
+
return lines
|
|
519
|
+
|
|
520
|
+
|
|
521
|
+
class MapperApp(App[None]):
|
|
522
|
+
CSS = """
|
|
523
|
+
Screen { layout: vertical; }
|
|
524
|
+
|
|
525
|
+
#info {
|
|
526
|
+
height: 3;
|
|
527
|
+
border: solid cyan;
|
|
528
|
+
padding: 0 1;
|
|
529
|
+
content-align: left middle;
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
#panels {
|
|
533
|
+
layout: horizontal;
|
|
534
|
+
height: 9;
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
#active-pane {
|
|
538
|
+
border: solid green;
|
|
539
|
+
width: 1fr;
|
|
540
|
+
padding: 0 1;
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
#recent-pane {
|
|
544
|
+
border: solid yellow;
|
|
545
|
+
width: 1fr;
|
|
546
|
+
padding: 0 1;
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
#log {
|
|
550
|
+
border: solid grey;
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
Input {
|
|
554
|
+
dock: bottom;
|
|
555
|
+
}
|
|
556
|
+
"""
|
|
557
|
+
|
|
558
|
+
def __init__(
|
|
559
|
+
self,
|
|
560
|
+
joy: object,
|
|
561
|
+
initial_profile: Profile,
|
|
562
|
+
initial_stem: str = "default",
|
|
563
|
+
) -> None:
|
|
564
|
+
super().__init__()
|
|
565
|
+
self._current: list = [initial_profile, initial_stem]
|
|
566
|
+
self._lock = threading.Lock()
|
|
567
|
+
self._active: set[str] = set()
|
|
568
|
+
self._recent: deque[str] = deque(maxlen=_RECENT_MAX)
|
|
569
|
+
self._mapper: MapperThread | None = None
|
|
570
|
+
self._edit_axis_state: dict[tuple[int, int], bool] = {}
|
|
571
|
+
self.controller_name = joy.get_name() if hasattr(joy, "get_name") else "Unknown"
|
|
572
|
+
|
|
573
|
+
def compose(self) -> ComposeResult:
|
|
574
|
+
yield Static("", id="info")
|
|
575
|
+
with Horizontal(id="panels"):
|
|
576
|
+
yield Static("[bold]Active[/]\n[dim]—[/]", id="active-pane")
|
|
577
|
+
yield Static("[bold]Last Press[/]\n[dim]—[/]", id="recent-pane")
|
|
578
|
+
yield RichLog(id="log", markup=True, highlight=True)
|
|
579
|
+
yield Input(placeholder="> ", id="cmd")
|
|
580
|
+
|
|
581
|
+
def on_mount(self) -> None:
|
|
582
|
+
self.push_screen(
|
|
583
|
+
SplashScreen(self.controller_name, self._current[0].name),
|
|
584
|
+
self._after_splash,
|
|
585
|
+
)
|
|
586
|
+
self._refresh_info()
|
|
587
|
+
log = self.query_one("#log", RichLog)
|
|
588
|
+
log.write("[bold cyan]Welcome to DS4-Mapper.[/]")
|
|
589
|
+
log.write("The default profile is loaded. To change it, use [bold]switch <name>[/].")
|
|
590
|
+
log.write("Below are a list of commands:")
|
|
591
|
+
for line in [
|
|
592
|
+
"[bold]list[/] — print all available profile names",
|
|
593
|
+
"[bold]switch [cyan]<name>[/][/] — load and activate a different profile",
|
|
594
|
+
"[bold]current[/] — refresh the active-keys and recent-press panels",
|
|
595
|
+
"[bold]reload[/] — re-read the current profile file from disk",
|
|
596
|
+
"[bold]view [cyan][<name>][/][/] — show button/axis/trigger mappings for a profile",
|
|
597
|
+
"[bold]edit [cyan][<name>][/][/] — interactively edit a profile's key mappings",
|
|
598
|
+
"[bold]new [cyan]<name>[/][/] — create a new empty profile",
|
|
599
|
+
"[bold]help[/] — show this command reference",
|
|
600
|
+
"[bold]quit[/] — stop mapping and exit",
|
|
601
|
+
]:
|
|
602
|
+
log.write(line)
|
|
603
|
+
|
|
604
|
+
def on_event(label: str, key_repr: str, pressed: bool) -> None:
|
|
605
|
+
display = _event_label(label)
|
|
606
|
+
with self._lock:
|
|
607
|
+
if pressed:
|
|
608
|
+
self._active.add(label)
|
|
609
|
+
self._recent.appendleft(f"{display} → {key_repr}")
|
|
610
|
+
else:
|
|
611
|
+
self._active.discard(label)
|
|
612
|
+
self.call_from_thread(self._refresh_panels)
|
|
613
|
+
|
|
614
|
+
self._mapper = MapperThread(self._get_profile, on_event)
|
|
615
|
+
self._mapper.start()
|
|
616
|
+
# macOS requires pygame.event.get() on the main thread (AppKit constraint).
|
|
617
|
+
# Textual's set_interval callbacks run in the asyncio event loop on the main thread.
|
|
618
|
+
self.set_interval(1 / 30, self._pump_pygame)
|
|
619
|
+
|
|
620
|
+
def _after_splash(self, _: None) -> None:
|
|
621
|
+
self.query_one("#cmd", Input).focus()
|
|
622
|
+
|
|
623
|
+
def _pump_pygame(self) -> None:
|
|
624
|
+
for event in pygame.event.get():
|
|
625
|
+
if self._mapper:
|
|
626
|
+
self._mapper.feed(event)
|
|
627
|
+
screen = self.screen
|
|
628
|
+
if not isinstance(screen, EditScreen):
|
|
629
|
+
continue
|
|
630
|
+
if event.type == pygame.JOYBUTTONDOWN:
|
|
631
|
+
screen.receive_ds4(f"button:{event.button}")
|
|
632
|
+
elif event.type == pygame.JOYAXISMOTION:
|
|
633
|
+
axis, value = event.axis, event.value
|
|
634
|
+
if axis in _TRIGGER_AXES:
|
|
635
|
+
was = self._edit_axis_state.get((axis, 0), False)
|
|
636
|
+
now = value > TRIGGER_DEADZONE
|
|
637
|
+
if now and not was:
|
|
638
|
+
screen.receive_ds4(f"trigger:{axis}")
|
|
639
|
+
self._edit_axis_state[(axis, 0)] = now
|
|
640
|
+
else:
|
|
641
|
+
for direction, sign in ((1, "+"), (-1, "-")):
|
|
642
|
+
was = self._edit_axis_state.get((axis, direction), False)
|
|
643
|
+
now = (value > DEADZONE) if direction > 0 else (value < -DEADZONE)
|
|
644
|
+
if now and not was:
|
|
645
|
+
screen.receive_ds4(f"axis:{axis}:{sign}")
|
|
646
|
+
self._edit_axis_state[(axis, direction)] = now
|
|
647
|
+
|
|
648
|
+
def _get_profile(self) -> Profile:
|
|
649
|
+
with self._lock:
|
|
650
|
+
return self._current[0]
|
|
651
|
+
|
|
652
|
+
def _refresh_info(self) -> None:
|
|
653
|
+
with self._lock:
|
|
654
|
+
p = self._current[0]
|
|
655
|
+
desc = f" [dim]-[/] {p.description}" if p.description else ""
|
|
656
|
+
self.query_one("#info", Static).update(
|
|
657
|
+
f"[dim]Profile:[/] [bold cyan]{p.name}[/]{desc}"
|
|
658
|
+
f" [dim]│[/] [dim]{self.controller_name}[/]"
|
|
659
|
+
)
|
|
660
|
+
|
|
661
|
+
def _refresh_panels(self) -> None:
|
|
662
|
+
with self._lock:
|
|
663
|
+
act = set(self._active)
|
|
664
|
+
rec = list(self._recent)
|
|
665
|
+
|
|
666
|
+
active_lines = (
|
|
667
|
+
"\n".join(f"[bold green]{_event_label(a)}[/]" for a in sorted(act))
|
|
668
|
+
if act
|
|
669
|
+
else "[dim]—[/]"
|
|
670
|
+
)
|
|
671
|
+
recent_lines = "\n".join(rec) if rec else "[dim]—[/]"
|
|
672
|
+
|
|
673
|
+
self.query_one("#active-pane", Static).update(f"[bold]Active[/]\n{active_lines}")
|
|
674
|
+
self.query_one("#recent-pane", Static).update(f"[bold]Last Press[/]\n{recent_lines}")
|
|
675
|
+
|
|
676
|
+
def on_input_submitted(self, event: Input.Submitted) -> None:
|
|
677
|
+
line = event.value.strip()
|
|
678
|
+
log = self.query_one("#log", RichLog)
|
|
679
|
+
event.input.clear()
|
|
680
|
+
|
|
681
|
+
if not line:
|
|
682
|
+
return
|
|
683
|
+
|
|
684
|
+
parts = line.split()
|
|
685
|
+
cmd = parts[0].lower()
|
|
686
|
+
|
|
687
|
+
log.write("")
|
|
688
|
+
log.write(f"[bold]> {line}[/]")
|
|
689
|
+
|
|
690
|
+
if cmd == "list":
|
|
691
|
+
names = list_profiles()
|
|
692
|
+
log.write(", ".join(names) if names else "(none)")
|
|
693
|
+
elif cmd == "switch":
|
|
694
|
+
if len(parts) < 2:
|
|
695
|
+
log.write("[red]usage:[/] switch [bold]<name>[/]")
|
|
696
|
+
else:
|
|
697
|
+
name = parts[1]
|
|
698
|
+
try:
|
|
699
|
+
p = load_profile(name)
|
|
700
|
+
with self._lock:
|
|
701
|
+
self._current[0] = p
|
|
702
|
+
self._current[1] = name
|
|
703
|
+
log.write(f"Switched to [cyan bold]{p.name}[/]")
|
|
704
|
+
self._refresh_info()
|
|
705
|
+
except (FileNotFoundError, ValueError, KeyError, IndexError, TypeError) as exc:
|
|
706
|
+
log.write(f"[red]Error:[/] {exc}")
|
|
707
|
+
elif cmd == "view":
|
|
708
|
+
if len(parts) >= 2:
|
|
709
|
+
try:
|
|
710
|
+
p = load_profile(parts[1])
|
|
711
|
+
except (FileNotFoundError, ValueError, KeyError, IndexError, TypeError) as exc:
|
|
712
|
+
log.write(f"[red]Error:[/] {exc}")
|
|
713
|
+
return
|
|
714
|
+
else:
|
|
715
|
+
with self._lock:
|
|
716
|
+
p = self._current[0]
|
|
717
|
+
for entry in _profile_lines(p):
|
|
718
|
+
log.write(entry)
|
|
719
|
+
elif cmd in ("current", "status"):
|
|
720
|
+
self._refresh_panels()
|
|
721
|
+
elif cmd == "reload":
|
|
722
|
+
with self._lock:
|
|
723
|
+
stem = self._current[1]
|
|
724
|
+
try:
|
|
725
|
+
p = load_profile(stem)
|
|
726
|
+
with self._lock:
|
|
727
|
+
self._current[0] = p
|
|
728
|
+
log.write(f"Reloaded [cyan bold]{p.name}[/]")
|
|
729
|
+
self._refresh_info()
|
|
730
|
+
except (FileNotFoundError, ValueError, KeyError, IndexError, TypeError) as exc:
|
|
731
|
+
log.write(f"[red]Error:[/] {exc}")
|
|
732
|
+
elif cmd == "edit":
|
|
733
|
+
stem = parts[1] if len(parts) >= 2 else self._current[1]
|
|
734
|
+
try:
|
|
735
|
+
p = load_profile(stem)
|
|
736
|
+
except (FileNotFoundError, ValueError, KeyError, IndexError, TypeError) as exc:
|
|
737
|
+
log.write(f"[red]Error:[/] {exc}")
|
|
738
|
+
return
|
|
739
|
+
if self._mapper:
|
|
740
|
+
self._mapper.suspended = True
|
|
741
|
+
|
|
742
|
+
def _after_edit(_: None) -> None:
|
|
743
|
+
if self._mapper:
|
|
744
|
+
self._mapper.suspended = False
|
|
745
|
+
self._refresh_info()
|
|
746
|
+
|
|
747
|
+
self.push_screen(EditScreen(p, stem), _after_edit)
|
|
748
|
+
elif cmd == "new":
|
|
749
|
+
if len(parts) < 2:
|
|
750
|
+
log.write("[red]usage:[/] new [bold]<stem>[/]")
|
|
751
|
+
return
|
|
752
|
+
stem = parts[1]
|
|
753
|
+
if (PROFILES_DIR / f"{stem}.toml").exists():
|
|
754
|
+
log.write(
|
|
755
|
+
f"[red]Error:[/] profile [cyan]{stem!r}[/] already exists"
|
|
756
|
+
" — use [bold]edit[/] to modify it"
|
|
757
|
+
)
|
|
758
|
+
return
|
|
759
|
+
p = Profile(name=stem, description="", buttons={}, axes={}, triggers={})
|
|
760
|
+
if self._mapper:
|
|
761
|
+
self._mapper.suspended = True
|
|
762
|
+
|
|
763
|
+
def _after_new(_: None) -> None:
|
|
764
|
+
if self._mapper:
|
|
765
|
+
self._mapper.suspended = False
|
|
766
|
+
|
|
767
|
+
self.push_screen(EditScreen(p, stem), _after_new)
|
|
768
|
+
elif cmd in ("help", "?"):
|
|
769
|
+
for entry in [
|
|
770
|
+
"[bold]list[/] — print all available profile names",
|
|
771
|
+
"[bold]switch [cyan]<name>[/][/] — load and activate a different profile",
|
|
772
|
+
"[bold]current[/] — refresh the active-keys and recent-press panels",
|
|
773
|
+
"[bold]reload[/] — re-read the current profile file from disk",
|
|
774
|
+
"[bold]view [cyan][<name>][/][/] — show mappings for a profile",
|
|
775
|
+
"[bold]edit [cyan][<name>][/][/] — interactively edit a profile's key mappings",
|
|
776
|
+
"[bold]new [cyan]<name>[/][/] — create a new empty profile",
|
|
777
|
+
"[bold]help[/] — show this command reference",
|
|
778
|
+
"[bold]quit[/] — stop mapping and exit",
|
|
779
|
+
]:
|
|
780
|
+
log.write(entry)
|
|
781
|
+
elif cmd in ("quit", "exit"):
|
|
782
|
+
self.exit()
|
|
783
|
+
else:
|
|
784
|
+
log.write(f"Unknown: [bold]{cmd!r}[/] — type [bold]help[/]")
|
|
785
|
+
|
|
786
|
+
def on_unmount(self) -> None:
|
|
787
|
+
if self._mapper:
|
|
788
|
+
self._mapper.stop()
|
|
789
|
+
self._mapper.join(timeout=2)
|
|
790
|
+
self._mapper.release_all()
|
|
791
|
+
|
|
792
|
+
|
|
793
|
+
def run(joy: object, initial_profile: Profile, initial_stem: str = "default") -> None:
|
|
794
|
+
MapperApp(joy, initial_profile, initial_stem).run()
|
ds4mapper/keys.py
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
from pynput.keyboard import Key
|
|
2
|
+
|
|
3
|
+
_SPECIAL: dict[str, Key] = {
|
|
4
|
+
"enter": Key.enter,
|
|
5
|
+
"tab": Key.tab,
|
|
6
|
+
"up": Key.up,
|
|
7
|
+
"down": Key.down,
|
|
8
|
+
"left": Key.left,
|
|
9
|
+
"right": Key.right,
|
|
10
|
+
"space": Key.space,
|
|
11
|
+
"esc": Key.esc,
|
|
12
|
+
"shift": Key.shift,
|
|
13
|
+
"ctrl": Key.ctrl,
|
|
14
|
+
"alt": Key.alt,
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def resolve(name: str) -> Key | str:
|
|
19
|
+
if name in _SPECIAL:
|
|
20
|
+
return _SPECIAL[name]
|
|
21
|
+
if len(name) == 1:
|
|
22
|
+
return name
|
|
23
|
+
raise ValueError(f"unknown key: {name!r}")
|
ds4mapper/mapper.py
ADDED
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
import queue
|
|
2
|
+
import sys
|
|
3
|
+
import threading
|
|
4
|
+
from collections.abc import Callable
|
|
5
|
+
|
|
6
|
+
import pygame
|
|
7
|
+
from pynput.keyboard import Controller, Key
|
|
8
|
+
|
|
9
|
+
from ds4mapper.profiles import Profile
|
|
10
|
+
|
|
11
|
+
DEADZONE = 0.4
|
|
12
|
+
TRIGGER_DEADZONE = -0.5
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class MapperThread(threading.Thread):
|
|
16
|
+
def __init__(
|
|
17
|
+
self,
|
|
18
|
+
get_profile: Callable[[], Profile],
|
|
19
|
+
on_event: Callable[[str, str, bool], None],
|
|
20
|
+
keyboard: Controller | None = None,
|
|
21
|
+
) -> None:
|
|
22
|
+
super().__init__(daemon=True)
|
|
23
|
+
self._get_profile = get_profile
|
|
24
|
+
self._on_event = on_event
|
|
25
|
+
self._keyboard = keyboard or Controller()
|
|
26
|
+
self._stop_event = threading.Event()
|
|
27
|
+
self._event_queue: queue.Queue = queue.Queue()
|
|
28
|
+
self._axis_active: dict[tuple[int, int], bool] = {}
|
|
29
|
+
self._trigger_active: dict[int, bool] = {}
|
|
30
|
+
self._pressed_keys: dict[str, Key | str] = {}
|
|
31
|
+
self._suspended = False
|
|
32
|
+
|
|
33
|
+
@property
|
|
34
|
+
def suspended(self) -> bool:
|
|
35
|
+
return self._suspended
|
|
36
|
+
|
|
37
|
+
@suspended.setter
|
|
38
|
+
def suspended(self, value: bool) -> None:
|
|
39
|
+
self._suspended = value
|
|
40
|
+
|
|
41
|
+
def feed(self, event: object) -> None:
|
|
42
|
+
"""Called from the main thread to deliver a pygame event."""
|
|
43
|
+
self._event_queue.put(event)
|
|
44
|
+
|
|
45
|
+
def stop(self) -> None:
|
|
46
|
+
self._stop_event.set()
|
|
47
|
+
|
|
48
|
+
def release_all(self) -> None:
|
|
49
|
+
"""Release all currently pressed keys."""
|
|
50
|
+
for key in list(self._pressed_keys.values()):
|
|
51
|
+
try:
|
|
52
|
+
self._keyboard.release(key)
|
|
53
|
+
except Exception:
|
|
54
|
+
pass
|
|
55
|
+
self._pressed_keys.clear()
|
|
56
|
+
|
|
57
|
+
def run(self) -> None:
|
|
58
|
+
while not self._stop_event.is_set():
|
|
59
|
+
try:
|
|
60
|
+
event = self._event_queue.get(timeout=0.016)
|
|
61
|
+
except queue.Empty:
|
|
62
|
+
continue
|
|
63
|
+
self._process_event(event)
|
|
64
|
+
|
|
65
|
+
def _process_event(self, event: object) -> None:
|
|
66
|
+
profile = self._get_profile()
|
|
67
|
+
|
|
68
|
+
if event.type == pygame.JOYBUTTONDOWN:
|
|
69
|
+
key = profile.buttons.get(event.button)
|
|
70
|
+
if key is None:
|
|
71
|
+
return
|
|
72
|
+
if not self._suspended:
|
|
73
|
+
try:
|
|
74
|
+
self._keyboard.press(key)
|
|
75
|
+
except Exception as exc: # noqa: BLE001
|
|
76
|
+
print(f"[mapper] press failed: {exc}", file=sys.stderr)
|
|
77
|
+
self._pressed_keys[f"btn:{event.button}"] = key
|
|
78
|
+
self._on_event(f"button:{event.button}", repr(key), True)
|
|
79
|
+
|
|
80
|
+
elif event.type == pygame.JOYBUTTONUP:
|
|
81
|
+
slot = f"btn:{event.button}"
|
|
82
|
+
key = self._pressed_keys.pop(slot, None)
|
|
83
|
+
if key is None:
|
|
84
|
+
return
|
|
85
|
+
if not self._suspended:
|
|
86
|
+
try:
|
|
87
|
+
self._keyboard.release(key)
|
|
88
|
+
except Exception as exc: # noqa: BLE001
|
|
89
|
+
print(f"[mapper] release failed: {exc}", file=sys.stderr)
|
|
90
|
+
self._on_event(f"button:{event.button}", repr(key), False)
|
|
91
|
+
|
|
92
|
+
elif event.type == pygame.JOYAXISMOTION:
|
|
93
|
+
axis, value = event.axis, event.value
|
|
94
|
+
if axis in profile.axes:
|
|
95
|
+
neg_key, pos_key = profile.axes[axis]
|
|
96
|
+
self._update_axis(axis, -1, value < -DEADZONE, neg_key)
|
|
97
|
+
self._update_axis(axis, +1, value > DEADZONE, pos_key)
|
|
98
|
+
elif axis in profile.triggers:
|
|
99
|
+
key = profile.triggers[axis]
|
|
100
|
+
was = self._trigger_active.get(axis, False)
|
|
101
|
+
now = value > TRIGGER_DEADZONE
|
|
102
|
+
if now and not was:
|
|
103
|
+
if not self._suspended:
|
|
104
|
+
try:
|
|
105
|
+
self._keyboard.press(key)
|
|
106
|
+
except Exception as exc: # noqa: BLE001
|
|
107
|
+
print(f"[mapper] press failed: {exc}", file=sys.stderr)
|
|
108
|
+
self._pressed_keys[f"trigger:{axis}"] = key
|
|
109
|
+
self._on_event(f"trigger:{axis}", repr(key), True)
|
|
110
|
+
elif was and not now:
|
|
111
|
+
pressed_key = self._pressed_keys.pop(f"trigger:{axis}", key)
|
|
112
|
+
if not self._suspended:
|
|
113
|
+
try:
|
|
114
|
+
self._keyboard.release(pressed_key)
|
|
115
|
+
except Exception as exc: # noqa: BLE001
|
|
116
|
+
print(f"[mapper] release failed: {exc}", file=sys.stderr)
|
|
117
|
+
self._on_event(f"trigger:{axis}", repr(pressed_key), False)
|
|
118
|
+
self._trigger_active[axis] = now
|
|
119
|
+
|
|
120
|
+
elif event.type == pygame.JOYHATMOTION:
|
|
121
|
+
pass
|
|
122
|
+
|
|
123
|
+
def _update_axis(self, axis: int, direction: int, is_active: bool, key: Key | str) -> None:
|
|
124
|
+
key_id = (axis, direction)
|
|
125
|
+
was = self._axis_active.get(key_id, False)
|
|
126
|
+
label = f"axis:{axis}:{'+' if direction > 0 else '-'}"
|
|
127
|
+
if is_active and not was:
|
|
128
|
+
if not self._suspended:
|
|
129
|
+
try:
|
|
130
|
+
self._keyboard.press(key)
|
|
131
|
+
except Exception as exc: # noqa: BLE001
|
|
132
|
+
print(f"[mapper] press failed: {exc}", file=sys.stderr)
|
|
133
|
+
self._pressed_keys[f"axis:{axis}:{direction}"] = key
|
|
134
|
+
self._on_event(label, repr(key), True)
|
|
135
|
+
elif was and not is_active:
|
|
136
|
+
pressed_key = self._pressed_keys.pop(f"axis:{axis}:{direction}", key)
|
|
137
|
+
if not self._suspended:
|
|
138
|
+
try:
|
|
139
|
+
self._keyboard.release(pressed_key)
|
|
140
|
+
except Exception as exc: # noqa: BLE001
|
|
141
|
+
print(f"[mapper] release failed: {exc}", file=sys.stderr)
|
|
142
|
+
self._on_event(label, repr(pressed_key), False)
|
|
143
|
+
self._axis_active[key_id] = is_active
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
name = "default"
|
|
2
|
+
description = "Standard browser game layout"
|
|
3
|
+
|
|
4
|
+
[buttons]
|
|
5
|
+
0 = "x"
|
|
6
|
+
1 = "z"
|
|
7
|
+
2 = "s"
|
|
8
|
+
3 = "a"
|
|
9
|
+
4 = "v"
|
|
10
|
+
6 = "enter"
|
|
11
|
+
9 = "q"
|
|
12
|
+
10 = "e"
|
|
13
|
+
11 = "up"
|
|
14
|
+
12 = "down"
|
|
15
|
+
13 = "left"
|
|
16
|
+
14 = "right"
|
|
17
|
+
|
|
18
|
+
[axes]
|
|
19
|
+
0 = ["f", "h"]
|
|
20
|
+
1 = ["t", "g"]
|
|
21
|
+
2 = ["j", "l"]
|
|
22
|
+
3 = ["i", "k"]
|
|
23
|
+
|
|
24
|
+
[triggers]
|
|
25
|
+
4 = "tab"
|
|
26
|
+
5 = "r"
|
ds4mapper/profiles.py
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import tomllib
|
|
2
|
+
from dataclasses import dataclass
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
from pynput.keyboard import Key
|
|
6
|
+
|
|
7
|
+
from ds4mapper.keys import resolve
|
|
8
|
+
|
|
9
|
+
PROFILES_DIR = Path(__file__).parent / "profiles"
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass
|
|
13
|
+
class Profile:
|
|
14
|
+
name: str
|
|
15
|
+
description: str
|
|
16
|
+
buttons: dict[int, Key | str]
|
|
17
|
+
axes: dict[int, tuple[Key | str, Key | str]]
|
|
18
|
+
triggers: dict[int, Key | str]
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def load_profile(name: str) -> Profile:
|
|
22
|
+
path = PROFILES_DIR / f"{name}.toml"
|
|
23
|
+
if not path.exists():
|
|
24
|
+
raise FileNotFoundError(f"profile not found: {name!r}")
|
|
25
|
+
|
|
26
|
+
with path.open("rb") as f:
|
|
27
|
+
data = tomllib.load(f)
|
|
28
|
+
|
|
29
|
+
if "buttons" not in data:
|
|
30
|
+
raise ValueError("profile missing required section: buttons")
|
|
31
|
+
if "axes" not in data:
|
|
32
|
+
raise ValueError("profile missing required section: axes")
|
|
33
|
+
if "triggers" not in data:
|
|
34
|
+
raise ValueError("profile missing required section: triggers")
|
|
35
|
+
|
|
36
|
+
buttons = {int(k): resolve(v) for k, v in data["buttons"].items()}
|
|
37
|
+
axes = {int(k): (resolve(v[0]), resolve(v[1])) for k, v in data["axes"].items()}
|
|
38
|
+
triggers = {int(k): resolve(v) for k, v in data["triggers"].items()}
|
|
39
|
+
|
|
40
|
+
return Profile(
|
|
41
|
+
name=data.get("name", name),
|
|
42
|
+
description=data.get("description", ""),
|
|
43
|
+
buttons=buttons,
|
|
44
|
+
axes=axes,
|
|
45
|
+
triggers=triggers,
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def list_profiles() -> list[str]:
|
|
50
|
+
return sorted(p.stem for p in PROFILES_DIR.glob("*.toml"))
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def key_to_toml(k: Key | str) -> str:
|
|
54
|
+
"""Serialize a pynput key back to its TOML string form."""
|
|
55
|
+
return k if isinstance(k, str) else k.name
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def save_profile(p: Profile, stem: str) -> None:
|
|
59
|
+
"""Write a Profile to PROFILES_DIR/<stem>.toml."""
|
|
60
|
+
path = PROFILES_DIR / f"{stem}.toml"
|
|
61
|
+
lines: list[str] = [f'name = "{p.name}"']
|
|
62
|
+
if p.description:
|
|
63
|
+
lines.append(f'description = "{p.description}"')
|
|
64
|
+
lines += ["", "[buttons]"]
|
|
65
|
+
for idx in sorted(p.buttons):
|
|
66
|
+
lines.append(f'{idx:<2} = "{key_to_toml(p.buttons[idx])}"')
|
|
67
|
+
lines += ["", "[axes]"]
|
|
68
|
+
for idx in sorted(p.axes):
|
|
69
|
+
neg = key_to_toml(p.axes[idx][0])
|
|
70
|
+
pos = key_to_toml(p.axes[idx][1])
|
|
71
|
+
if "?" not in (neg, pos):
|
|
72
|
+
lines.append(f'{idx:<2} = ["{neg}", "{pos}"]')
|
|
73
|
+
lines += ["", "[triggers]"]
|
|
74
|
+
for idx in sorted(p.triggers):
|
|
75
|
+
lines.append(f'{idx:<2} = "{key_to_toml(p.triggers[idx])}"')
|
|
76
|
+
path.write_text("\n".join(lines) + "\n")
|