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.
@@ -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,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ ds4-mapper = ds4mapper.__main__:main
@@ -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")