controller-companion 0.4.0__tar.gz
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.
- controller_companion-0.4.0/LICENSE +21 -0
- controller_companion-0.4.0/PKG-INFO +66 -0
- controller_companion-0.4.0/README.md +44 -0
- controller_companion-0.4.0/controller_companion/__init__.py +4 -0
- controller_companion-0.4.0/controller_companion/app/__init__.py +0 -0
- controller_companion-0.4.0/controller_companion/app/app.py +304 -0
- controller_companion-0.4.0/controller_companion/app/popup_about.py +49 -0
- controller_companion-0.4.0/controller_companion/app/popup_create_action.py +240 -0
- controller_companion-0.4.0/controller_companion/app/resources.py +32 -0
- controller_companion-0.4.0/controller_companion/app/widgets/__init__.py +0 -0
- controller_companion-0.4.0/controller_companion/app/widgets/placeholder_entry.py +37 -0
- controller_companion-0.4.0/controller_companion/controller.py +10 -0
- controller_companion-0.4.0/controller_companion/controller_observer.py +301 -0
- controller_companion-0.4.0/controller_companion/controller_state.py +74 -0
- controller_companion-0.4.0/controller_companion/logs.py +15 -0
- controller_companion-0.4.0/controller_companion/mapping.py +67 -0
- controller_companion-0.4.0/controller_companion/pyinstaller.py +33 -0
- controller_companion-0.4.0/controller_companion/res/app.ico +0 -0
- controller_companion-0.4.0/controller_companion/res/app.png +0 -0
- controller_companion-0.4.0/pyproject.toml +31 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2024 Johannes Gundlach
|
|
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,66 @@
|
|
|
1
|
+
Metadata-Version: 2.1
|
|
2
|
+
Name: controller-companion
|
|
3
|
+
Version: 0.4.0
|
|
4
|
+
Summary:
|
|
5
|
+
Author: Johannes Gundlach
|
|
6
|
+
Author-email: 24914225+Johannes11833@users.noreply.github.com
|
|
7
|
+
Requires-Python: >=3.9,<3.14
|
|
8
|
+
Classifier: Programming Language :: Python :: 3
|
|
9
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
10
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
11
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
14
|
+
Requires-Dist: pillow (>=11.0.0,<12.0.0)
|
|
15
|
+
Requires-Dist: pyautogui (>=0.9.54,<0.10.0)
|
|
16
|
+
Requires-Dist: pygame (>=2.6.1,<3.0.0)
|
|
17
|
+
Requires-Dist: pystray (>=0.19.5,<0.20.0)
|
|
18
|
+
Requires-Dist: requests (>=2.32.3,<3.0.0)
|
|
19
|
+
Requires-Dist: rich (>=13.9.4,<14.0.0)
|
|
20
|
+
Description-Content-Type: text/markdown
|
|
21
|
+
|
|
22
|
+
# Controller Companion
|
|
23
|
+
|
|
24
|
+
Easily map controller shortcuts to an action.
|
|
25
|
+
The following actions are supported:
|
|
26
|
+
- Kill a task by its name.
|
|
27
|
+
- Execute a keyboard shortcut.
|
|
28
|
+
- Execute an arbitrary console command.
|
|
29
|
+
|
|
30
|
+
## Features
|
|
31
|
+
- GUI and CLI options available
|
|
32
|
+
- Supports a wide variety of controllers (those supported by [pygame](https://www.pygame.org))
|
|
33
|
+
- Runs on Windows, Linux and Mac
|
|
34
|
+
- Auto start on system boot (windows only for now)
|
|
35
|
+
- GUI app can be compiled as a standalone executable (using [PyInstaller](https://pyinstaller.org)
|
|
36
|
+
|
|
37
|
+
## How to run
|
|
38
|
+
- CLI and GUI versions are accessible through [poetry](https://python-poetry.org/)
|
|
39
|
+
- CLI:
|
|
40
|
+
- Multiple mappings can be created.
|
|
41
|
+
- One or multiple input controller key combination that triggers an action can be defined using --input. The keys of one individual input combination are separated by commas, while each distinct input combination is separated by a space.
|
|
42
|
+
- One or multiple actions can be defined using either `--task-kill`, `--console` or `--shortcut`. These actions will be mapped to the previously defined controller --input key combinations in the order `--task-kill`, `--console` and finally `--shortcut`
|
|
43
|
+
- Example with 2 mappings:
|
|
44
|
+
```console
|
|
45
|
+
poetry run controller_companion --input A,B Back,Left --console explorer.exe --shortcut alt+f4
|
|
46
|
+
```
|
|
47
|
+
will result in the following mapping:
|
|
48
|
+
```console
|
|
49
|
+
Controller<A,B> --> Action<name: Run command "explorer.exe", target: explorer.exe, type: CONSOLE_COMMAND>
|
|
50
|
+
Controller<Back,Left> --> Action<name: Shortcut "alt+f4", target: alt+f4, type: KEYBOARD_SHORTCUT>
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
- Run `poetry run controller_companion --help` to see all available arguments
|
|
54
|
+
|
|
55
|
+
## Build Executable
|
|
56
|
+
With [poetry](https://python-poetry.org/) installed, run the following command to build the executable using [PyInstaller](https://pyinstaller.org):
|
|
57
|
+
```console
|
|
58
|
+
poetry run build
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
## Screenshots
|
|
62
|
+
### Main Window
|
|
63
|
+

|
|
64
|
+
### Create a new shortcut
|
|
65
|
+

|
|
66
|
+
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# Controller Companion
|
|
2
|
+
|
|
3
|
+
Easily map controller shortcuts to an action.
|
|
4
|
+
The following actions are supported:
|
|
5
|
+
- Kill a task by its name.
|
|
6
|
+
- Execute a keyboard shortcut.
|
|
7
|
+
- Execute an arbitrary console command.
|
|
8
|
+
|
|
9
|
+
## Features
|
|
10
|
+
- GUI and CLI options available
|
|
11
|
+
- Supports a wide variety of controllers (those supported by [pygame](https://www.pygame.org))
|
|
12
|
+
- Runs on Windows, Linux and Mac
|
|
13
|
+
- Auto start on system boot (windows only for now)
|
|
14
|
+
- GUI app can be compiled as a standalone executable (using [PyInstaller](https://pyinstaller.org)
|
|
15
|
+
|
|
16
|
+
## How to run
|
|
17
|
+
- CLI and GUI versions are accessible through [poetry](https://python-poetry.org/)
|
|
18
|
+
- CLI:
|
|
19
|
+
- Multiple mappings can be created.
|
|
20
|
+
- One or multiple input controller key combination that triggers an action can be defined using --input. The keys of one individual input combination are separated by commas, while each distinct input combination is separated by a space.
|
|
21
|
+
- One or multiple actions can be defined using either `--task-kill`, `--console` or `--shortcut`. These actions will be mapped to the previously defined controller --input key combinations in the order `--task-kill`, `--console` and finally `--shortcut`
|
|
22
|
+
- Example with 2 mappings:
|
|
23
|
+
```console
|
|
24
|
+
poetry run controller_companion --input A,B Back,Left --console explorer.exe --shortcut alt+f4
|
|
25
|
+
```
|
|
26
|
+
will result in the following mapping:
|
|
27
|
+
```console
|
|
28
|
+
Controller<A,B> --> Action<name: Run command "explorer.exe", target: explorer.exe, type: CONSOLE_COMMAND>
|
|
29
|
+
Controller<Back,Left> --> Action<name: Shortcut "alt+f4", target: alt+f4, type: KEYBOARD_SHORTCUT>
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
- Run `poetry run controller_companion --help` to see all available arguments
|
|
33
|
+
|
|
34
|
+
## Build Executable
|
|
35
|
+
With [poetry](https://python-poetry.org/) installed, run the following command to build the executable using [PyInstaller](https://pyinstaller.org):
|
|
36
|
+
```console
|
|
37
|
+
poetry run build
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
## Screenshots
|
|
41
|
+
### Main Window
|
|
42
|
+

|
|
43
|
+
### Create a new shortcut
|
|
44
|
+

|
|
File without changes
|
|
@@ -0,0 +1,304 @@
|
|
|
1
|
+
import argparse
|
|
2
|
+
import json
|
|
3
|
+
import os
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
import threading
|
|
6
|
+
import tkinter as tk
|
|
7
|
+
from tkinter import Menu, messagebox
|
|
8
|
+
import webbrowser
|
|
9
|
+
import requests
|
|
10
|
+
import platform
|
|
11
|
+
from tkinter import ttk
|
|
12
|
+
import pystray
|
|
13
|
+
from PIL import Image
|
|
14
|
+
import controller_companion
|
|
15
|
+
from controller_companion.logs import logger
|
|
16
|
+
from controller_companion.mapping import Mapping
|
|
17
|
+
from controller_companion.app import resources
|
|
18
|
+
from controller_companion.app.popup_about import AboutScreen
|
|
19
|
+
from controller_companion.app.popup_create_action import CreateActionPopup
|
|
20
|
+
import controller_companion.controller_observer as controller_observer
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class ControllerCompanion(tk.Tk):
|
|
24
|
+
def __init__(self, launch_minimized=False):
|
|
25
|
+
super().__init__()
|
|
26
|
+
|
|
27
|
+
self.title("Controller Companion")
|
|
28
|
+
self.iconbitmap(resources.APP_ICON_ICO)
|
|
29
|
+
self.geometry("550x280")
|
|
30
|
+
self.protocol("WM_DELETE_WINDOW", self.minimize_to_tray)
|
|
31
|
+
self.app_path = Path.home() / "Documents" / "Controller Companion"
|
|
32
|
+
self.settings_file = self.app_path / "settings.json"
|
|
33
|
+
|
|
34
|
+
# load settings
|
|
35
|
+
self.settings = self.load_settings()
|
|
36
|
+
|
|
37
|
+
# --------------------------------- add menu --------------------------------- #
|
|
38
|
+
menu = Menu(self)
|
|
39
|
+
self.config(menu=menu)
|
|
40
|
+
|
|
41
|
+
# File Menu
|
|
42
|
+
filemenu_ = Menu(menu, tearoff=False)
|
|
43
|
+
menu.add_cascade(label="File", menu=filemenu_)
|
|
44
|
+
filemenu_.add_command(
|
|
45
|
+
label="Add mapping", command=self.open_add_actions, accelerator="Ctrl+N"
|
|
46
|
+
)
|
|
47
|
+
filemenu_.add_command(
|
|
48
|
+
label="Delete mapping",
|
|
49
|
+
command=self.delete_action,
|
|
50
|
+
accelerator="Del",
|
|
51
|
+
)
|
|
52
|
+
open_config = lambda: os.startfile(self.settings_file)
|
|
53
|
+
filemenu_.add_command(
|
|
54
|
+
label="Open config file",
|
|
55
|
+
command=open_config,
|
|
56
|
+
accelerator="Ctrl+C",
|
|
57
|
+
)
|
|
58
|
+
self.bind_all("<Delete>", self.delete_action)
|
|
59
|
+
self.bind_all("<Control-n>", self.open_add_actions)
|
|
60
|
+
self.bind_all("<Control-q>", self.quit_window)
|
|
61
|
+
self.bind_all("<Control-c>", lambda _: open_config())
|
|
62
|
+
filemenu_.add_separator()
|
|
63
|
+
filemenu_.add_command(
|
|
64
|
+
label="Quit",
|
|
65
|
+
command=self.quit_window,
|
|
66
|
+
accelerator="Ctrl+Q",
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
# Settings Menu
|
|
70
|
+
settings_ = Menu(menu, tearoff=0)
|
|
71
|
+
self.var_settings_minimize_on_close = tk.IntVar()
|
|
72
|
+
self.var_settings_auto_start = tk.IntVar()
|
|
73
|
+
self.var_settings_minimize_on_close.set(self.settings["minimize_on_exit"])
|
|
74
|
+
self.var_settings_auto_start.set(self.settings["auto_start"])
|
|
75
|
+
menu.add_cascade(label="Settings", menu=settings_)
|
|
76
|
+
settings_.add_checkbutton(
|
|
77
|
+
label="Minimize to system tray",
|
|
78
|
+
onvalue=1,
|
|
79
|
+
offvalue=0,
|
|
80
|
+
variable=self.var_settings_minimize_on_close,
|
|
81
|
+
command=self.save_settings,
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
if resources.is_frozen():
|
|
85
|
+
# only display auto start option if this is an executable
|
|
86
|
+
settings_.add_checkbutton(
|
|
87
|
+
label="Auto Start",
|
|
88
|
+
onvalue=1,
|
|
89
|
+
offvalue=0,
|
|
90
|
+
variable=self.var_settings_auto_start,
|
|
91
|
+
command=self.toggle_autostart,
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
# Help Menu
|
|
95
|
+
help_ = Menu(menu, tearoff=0)
|
|
96
|
+
menu.add_cascade(label="Help", menu=help_)
|
|
97
|
+
help_.add_command(label="Check for Updates", command=self.check_for_updates)
|
|
98
|
+
help_.add_command(label="About", command=lambda: AboutScreen(self))
|
|
99
|
+
|
|
100
|
+
# ---------------------------------------------------------------------------- #
|
|
101
|
+
|
|
102
|
+
tk.Label(self, text="Defined Mappings").pack(fill=tk.X)
|
|
103
|
+
self.treeview = ttk.Treeview(columns=("shortcut", "target"), height=7)
|
|
104
|
+
self.treeview.heading("#0", text="Name")
|
|
105
|
+
self.treeview.heading("shortcut", text="Shortcut")
|
|
106
|
+
self.treeview.heading("target", text="Target")
|
|
107
|
+
for mapping in self.defined_actions:
|
|
108
|
+
self.treeview.insert(
|
|
109
|
+
"",
|
|
110
|
+
tk.END,
|
|
111
|
+
text=mapping.name,
|
|
112
|
+
values=(
|
|
113
|
+
mapping.controller_state.describe(),
|
|
114
|
+
mapping.target,
|
|
115
|
+
),
|
|
116
|
+
)
|
|
117
|
+
self.treeview.pack(expand=True, fill=tk.BOTH)
|
|
118
|
+
|
|
119
|
+
# --------------------------- connected controllers -------------------------- #
|
|
120
|
+
|
|
121
|
+
tk.Label(self, text="Connected Controllers").pack(fill=tk.X)
|
|
122
|
+
self.var_connected_controllers = tk.Variable()
|
|
123
|
+
listbox_controllers = tk.Listbox(
|
|
124
|
+
self,
|
|
125
|
+
listvariable=self.var_connected_controllers,
|
|
126
|
+
selectmode=tk.EXTENDED,
|
|
127
|
+
)
|
|
128
|
+
listbox_controllers.pack(expand=True, fill=tk.BOTH)
|
|
129
|
+
|
|
130
|
+
# -------------------- start the joystick observer thread -------------------- #
|
|
131
|
+
self.thread = threading.Thread(
|
|
132
|
+
target=controller_observer.start_observer,
|
|
133
|
+
daemon=True,
|
|
134
|
+
args=[
|
|
135
|
+
self.defined_actions,
|
|
136
|
+
],
|
|
137
|
+
kwargs={
|
|
138
|
+
"debug": self.settings.get("debug", 0) == 1,
|
|
139
|
+
"controller_callback": lambda update: self.var_connected_controllers.set(
|
|
140
|
+
[f" {c.name}" for c in update]
|
|
141
|
+
),
|
|
142
|
+
},
|
|
143
|
+
)
|
|
144
|
+
self.thread.start()
|
|
145
|
+
# ---------------------------------------------------------------------------- #
|
|
146
|
+
|
|
147
|
+
if launch_minimized:
|
|
148
|
+
# use after to make initial controller connected callback work
|
|
149
|
+
# otherwise, we would get a RuntimeError: main thread is not in main loop
|
|
150
|
+
# when executing the controller_callback above.
|
|
151
|
+
self.after(100, self.minimize_to_tray, [True])
|
|
152
|
+
|
|
153
|
+
def minimize_to_tray(self, is_launch: bool = False):
|
|
154
|
+
if self.var_settings_minimize_on_close.get() == 0 and not is_launch:
|
|
155
|
+
# exit the app instead as minimize to system tray is disabled
|
|
156
|
+
self.quit_window()
|
|
157
|
+
return
|
|
158
|
+
|
|
159
|
+
self.withdraw()
|
|
160
|
+
image = Image.open(resources.APP_ICON_ICO)
|
|
161
|
+
menu = (
|
|
162
|
+
pystray.MenuItem("Show", self.show_window),
|
|
163
|
+
pystray.MenuItem("Quit", self.quit_window_from_icon),
|
|
164
|
+
)
|
|
165
|
+
icon = pystray.Icon("name", image, "Controller Companion", menu)
|
|
166
|
+
icon.run()
|
|
167
|
+
|
|
168
|
+
def quit_window_from_icon(self, icon=None):
|
|
169
|
+
if icon is not None:
|
|
170
|
+
icon.stop()
|
|
171
|
+
self.quit_window()
|
|
172
|
+
|
|
173
|
+
def quit_window(self, _=None):
|
|
174
|
+
self.thread.do_run = False
|
|
175
|
+
self.thread.join()
|
|
176
|
+
print("Thread ended")
|
|
177
|
+
self.destroy()
|
|
178
|
+
|
|
179
|
+
def show_window(self, icon):
|
|
180
|
+
icon.stop()
|
|
181
|
+
self.after(0, self.deiconify)
|
|
182
|
+
|
|
183
|
+
def open_add_actions(self, _=None):
|
|
184
|
+
p = CreateActionPopup(self)
|
|
185
|
+
result = p.result
|
|
186
|
+
if result is not None:
|
|
187
|
+
self.defined_actions.append(result)
|
|
188
|
+
self.treeview.insert(
|
|
189
|
+
"",
|
|
190
|
+
tk.END,
|
|
191
|
+
text=result.name,
|
|
192
|
+
values=(
|
|
193
|
+
result.controller_state.describe(),
|
|
194
|
+
result.target,
|
|
195
|
+
),
|
|
196
|
+
image=tk.PhotoImage(file=resources.APP_ICON_PNG),
|
|
197
|
+
)
|
|
198
|
+
self.save_settings()
|
|
199
|
+
|
|
200
|
+
def delete_action(self, _=None):
|
|
201
|
+
selection = self.treeview.selection()
|
|
202
|
+
for item in selection:
|
|
203
|
+
delete_idx = self.treeview.index(item)
|
|
204
|
+
self.treeview.delete(item)
|
|
205
|
+
self.defined_actions.pop(delete_idx)
|
|
206
|
+
|
|
207
|
+
if len(selection) > 0:
|
|
208
|
+
self.save_settings()
|
|
209
|
+
|
|
210
|
+
def load_settings(self):
|
|
211
|
+
settings = {
|
|
212
|
+
"minimize_on_exit": 1,
|
|
213
|
+
"auto_start": 0,
|
|
214
|
+
"debug": 0,
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
if self.settings_file.is_file():
|
|
218
|
+
settings.update(json.loads(self.settings_file.read_text()))
|
|
219
|
+
self.defined_actions = [Mapping.from_dict(d) for d in settings["actions"]]
|
|
220
|
+
else:
|
|
221
|
+
self.defined_actions = []
|
|
222
|
+
|
|
223
|
+
return settings
|
|
224
|
+
|
|
225
|
+
def save_settings(self):
|
|
226
|
+
# update the settings dict
|
|
227
|
+
self.settings.update(
|
|
228
|
+
{
|
|
229
|
+
"minimize_on_exit": self.var_settings_minimize_on_close.get(),
|
|
230
|
+
"auto_start": self.var_settings_auto_start.get(),
|
|
231
|
+
"actions": [item.to_dict() for item in self.defined_actions],
|
|
232
|
+
}
|
|
233
|
+
)
|
|
234
|
+
self.settings_file.parent.mkdir(exist_ok=True, parents=True)
|
|
235
|
+
self.settings_file.write_text(json.dumps(self.settings, indent=4))
|
|
236
|
+
|
|
237
|
+
print(f"Saved settings to: {self.settings_file}")
|
|
238
|
+
|
|
239
|
+
def toggle_autostart(self):
|
|
240
|
+
executable = resources.get_executable_path()
|
|
241
|
+
autostart = self.var_settings_auto_start.get() == 1
|
|
242
|
+
|
|
243
|
+
if platform.system() == "Windows":
|
|
244
|
+
bat_file = Path(
|
|
245
|
+
Path.home(),
|
|
246
|
+
"AppData/Roaming/Microsoft/Windows/Start Menu/Programs/Startup/controller_companion.bat",
|
|
247
|
+
)
|
|
248
|
+
if autostart:
|
|
249
|
+
bat_file.write_text(f'start "" "{executable.absolute()}" "-m"')
|
|
250
|
+
else:
|
|
251
|
+
bat_file.unlink(missing_ok=True)
|
|
252
|
+
|
|
253
|
+
self.save_settings()
|
|
254
|
+
|
|
255
|
+
def check_for_updates(self):
|
|
256
|
+
response = requests.get(
|
|
257
|
+
"https://api.github.com/repos/Johannes11833/controller-companion/releases/latest"
|
|
258
|
+
)
|
|
259
|
+
latest_version = response.json()["name"]
|
|
260
|
+
installed_version = controller_companion.VERSION
|
|
261
|
+
|
|
262
|
+
if latest_version == installed_version:
|
|
263
|
+
messagebox.showinfo(
|
|
264
|
+
"Up to date",
|
|
265
|
+
"The latest version of Controller Companion is installed.",
|
|
266
|
+
parent=self,
|
|
267
|
+
)
|
|
268
|
+
else:
|
|
269
|
+
url = (
|
|
270
|
+
"https://github.com/Johannes11833/controller-companion/releases/latest"
|
|
271
|
+
)
|
|
272
|
+
logger.info(
|
|
273
|
+
f"A new update is available: {installed_version} -> {latest_version}. URL: {url}"
|
|
274
|
+
)
|
|
275
|
+
open_website = messagebox.askyesno(
|
|
276
|
+
f"Update available: {latest_version}",
|
|
277
|
+
f"A new update is available for Controller Companion:\ninstalled: Go to download page now?",
|
|
278
|
+
parent=self,
|
|
279
|
+
)
|
|
280
|
+
if open_website:
|
|
281
|
+
webbrowser.open_new_tab(
|
|
282
|
+
url,
|
|
283
|
+
)
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
def cli():
|
|
287
|
+
parser = argparse.ArgumentParser(
|
|
288
|
+
description="Lauch the Controller Companion UI App."
|
|
289
|
+
)
|
|
290
|
+
parser.add_argument(
|
|
291
|
+
"-m",
|
|
292
|
+
"--minimized",
|
|
293
|
+
help="Launch minimized.",
|
|
294
|
+
action="store_true",
|
|
295
|
+
default=False,
|
|
296
|
+
)
|
|
297
|
+
args = parser.parse_args()
|
|
298
|
+
|
|
299
|
+
app = ControllerCompanion(launch_minimized=args.minimized)
|
|
300
|
+
app.mainloop()
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
if __name__ == "__main__":
|
|
304
|
+
cli()
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import tkinter as tk
|
|
2
|
+
from tkinter import ttk
|
|
3
|
+
import webbrowser
|
|
4
|
+
from PIL import Image, ImageTk
|
|
5
|
+
|
|
6
|
+
from controller_companion import VERSION
|
|
7
|
+
from controller_companion.app import resources
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class AboutScreen(tk.Toplevel):
|
|
11
|
+
def __init__(self, master, **kwargs):
|
|
12
|
+
super().__init__(master, **kwargs)
|
|
13
|
+
self.title("About")
|
|
14
|
+
self.iconbitmap(resources.APP_ICON_ICO)
|
|
15
|
+
self.resizable(False, False)
|
|
16
|
+
|
|
17
|
+
frame1 = tk.Frame(master=self, width=50, padx=10, pady=10)
|
|
18
|
+
frame1.pack(fill=tk.Y, side=tk.LEFT)
|
|
19
|
+
frame2 = tk.Frame(master=self, padx=10, pady=10)
|
|
20
|
+
frame2.pack(fill=tk.Y, side=tk.LEFT)
|
|
21
|
+
|
|
22
|
+
# Display it within a label.
|
|
23
|
+
image = ImageTk.PhotoImage(Image.open(resources.APP_ICON_PNG))
|
|
24
|
+
label = ttk.Label(frame1, image=image, width=50)
|
|
25
|
+
label.pack(side=tk.LEFT)
|
|
26
|
+
|
|
27
|
+
tk.Label(
|
|
28
|
+
frame2,
|
|
29
|
+
text=f"Controller Companion {VERSION}",
|
|
30
|
+
font=("Helvetica", 12, "bold"),
|
|
31
|
+
).pack(side=tk.TOP, anchor="w")
|
|
32
|
+
tk.Label(frame2, text=f"Made by Johannes Gundlach").pack(
|
|
33
|
+
side=tk.TOP, anchor="w"
|
|
34
|
+
)
|
|
35
|
+
link = tk.Label(
|
|
36
|
+
frame2,
|
|
37
|
+
text="https://github.com/Johannes11833/controller-companion",
|
|
38
|
+
fg="blue",
|
|
39
|
+
cursor="hand2",
|
|
40
|
+
)
|
|
41
|
+
link.pack(side=tk.TOP, anchor="w")
|
|
42
|
+
link.bind("<Button-1>", lambda _: self.callback(link.cget("text")))
|
|
43
|
+
|
|
44
|
+
self.mainloop()
|
|
45
|
+
|
|
46
|
+
def callback(self, url):
|
|
47
|
+
webbrowser.open_new_tab(
|
|
48
|
+
url,
|
|
49
|
+
)
|
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
import tkinter as tk
|
|
2
|
+
from tkinter import (
|
|
3
|
+
Checkbutton,
|
|
4
|
+
Event,
|
|
5
|
+
IntVar,
|
|
6
|
+
Radiobutton,
|
|
7
|
+
StringVar,
|
|
8
|
+
ttk,
|
|
9
|
+
)
|
|
10
|
+
from tkinter import messagebox
|
|
11
|
+
from controller_companion.logs import logger
|
|
12
|
+
|
|
13
|
+
from controller_companion.mapping import Mapping, ActionType
|
|
14
|
+
from controller_companion.app import resources
|
|
15
|
+
from controller_companion.app.widgets.placeholder_entry import PlaceholderEntry
|
|
16
|
+
from controller_companion.controller_state import (
|
|
17
|
+
ControllerState,
|
|
18
|
+
button_mapper,
|
|
19
|
+
d_pad_mapper,
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class CreateActionPopup(tk.Toplevel):
|
|
24
|
+
"""modal window requires a master"""
|
|
25
|
+
|
|
26
|
+
def __init__(self, master, **kwargs):
|
|
27
|
+
super().__init__(master, **kwargs)
|
|
28
|
+
|
|
29
|
+
self.iconbitmap(resources.APP_ICON_ICO)
|
|
30
|
+
|
|
31
|
+
self.var_buttons = {}
|
|
32
|
+
self.var_d_pad = IntVar()
|
|
33
|
+
self.var_action_type = StringVar()
|
|
34
|
+
self.var_command = StringVar()
|
|
35
|
+
self.result = None
|
|
36
|
+
|
|
37
|
+
frame_inputs = ttk.LabelFrame(
|
|
38
|
+
master=self, height=50, text="Controller Shortcut"
|
|
39
|
+
)
|
|
40
|
+
frame_inputs.pack(fill=tk.X, side=tk.TOP, expand=True, padx=10, pady=(10, 5))
|
|
41
|
+
|
|
42
|
+
frame_buttons = tk.Frame(master=frame_inputs, height=50, padx=5)
|
|
43
|
+
frame_buttons.pack(fill=tk.X, side=tk.TOP, expand=True)
|
|
44
|
+
|
|
45
|
+
frame_d_pad = tk.Frame(master=frame_inputs, height=50, padx=5)
|
|
46
|
+
frame_d_pad.pack(fill=tk.X, side=tk.TOP, expand=True)
|
|
47
|
+
|
|
48
|
+
frame_action = ttk.LabelFrame(
|
|
49
|
+
master=self, height=50, text="Action", padding=(0, 5, 5, 5)
|
|
50
|
+
)
|
|
51
|
+
frame_action.pack(fill=tk.X, side=tk.TOP, expand=True, padx=10, pady=5)
|
|
52
|
+
|
|
53
|
+
frame_save = ttk.LabelFrame(
|
|
54
|
+
master=self, height=50, text="Save", padding=(0, 5, 0, 5)
|
|
55
|
+
)
|
|
56
|
+
frame_save.pack(fill=tk.X, side=tk.TOP, expand=True, padx=10, pady=(5, 10))
|
|
57
|
+
|
|
58
|
+
tk.Label(frame_buttons, text="Buttons", anchor="w").grid(
|
|
59
|
+
row=0, column=0, sticky="W"
|
|
60
|
+
)
|
|
61
|
+
for column, button in enumerate(button_mapper.keys()):
|
|
62
|
+
self.var_buttons[button] = IntVar()
|
|
63
|
+
check = Checkbutton(
|
|
64
|
+
frame_buttons,
|
|
65
|
+
text=button,
|
|
66
|
+
variable=self.var_buttons[button],
|
|
67
|
+
anchor="w",
|
|
68
|
+
)
|
|
69
|
+
check.grid(row=1, column=column)
|
|
70
|
+
|
|
71
|
+
tk.Label(frame_d_pad, text="D-Pad", anchor="w").grid(
|
|
72
|
+
row=0, column=0, sticky="W"
|
|
73
|
+
)
|
|
74
|
+
self.var_d_pad.set(-1)
|
|
75
|
+
for column, d_pad_state in enumerate(d_pad_mapper.keys()):
|
|
76
|
+
check = Radiobutton(
|
|
77
|
+
frame_d_pad,
|
|
78
|
+
text=d_pad_state,
|
|
79
|
+
variable=self.var_d_pad,
|
|
80
|
+
value=column,
|
|
81
|
+
anchor="w",
|
|
82
|
+
)
|
|
83
|
+
check.grid(row=1, column=column)
|
|
84
|
+
|
|
85
|
+
tk.Label(
|
|
86
|
+
frame_action,
|
|
87
|
+
text="Type:",
|
|
88
|
+
padx=5,
|
|
89
|
+
).grid(row=1, column=0)
|
|
90
|
+
combo = ttk.Combobox(
|
|
91
|
+
frame_action,
|
|
92
|
+
values=[e.value for e in ActionType],
|
|
93
|
+
state="readonly",
|
|
94
|
+
textvariable=self.var_action_type,
|
|
95
|
+
)
|
|
96
|
+
combo.bind("<<ComboboxSelected>>", self.action_type_changed)
|
|
97
|
+
self.var_action_type.set(ActionType.TASK_KILL_BY_NAME.value)
|
|
98
|
+
combo.grid(row=1, column=1)
|
|
99
|
+
|
|
100
|
+
tk.Label(
|
|
101
|
+
frame_action,
|
|
102
|
+
text="Target:",
|
|
103
|
+
padx=5,
|
|
104
|
+
).grid(row=1, column=2)
|
|
105
|
+
self.entry_target_command = PlaceholderEntry(
|
|
106
|
+
frame_action,
|
|
107
|
+
textvariable=self.var_command,
|
|
108
|
+
)
|
|
109
|
+
frame_action.columnconfigure(3, weight=1)
|
|
110
|
+
self.entry_target_command.grid(row=1, column=3, sticky="nsew")
|
|
111
|
+
|
|
112
|
+
save_label = tk.Label(frame_save, text="Name:", padx=5)
|
|
113
|
+
save_label.grid(row=0, column=0, padx=5)
|
|
114
|
+
self.entry_name = PlaceholderEntry(
|
|
115
|
+
frame_save,
|
|
116
|
+
placeholder="name of the shortcut",
|
|
117
|
+
width=30,
|
|
118
|
+
)
|
|
119
|
+
self.entry_name.grid(row=0, column=1, padx=5)
|
|
120
|
+
tk.Button(frame_save, text="Save", command=self.on_save).grid(
|
|
121
|
+
row=0, column=2, padx=5
|
|
122
|
+
)
|
|
123
|
+
self.action_type_changed()
|
|
124
|
+
|
|
125
|
+
self.pressed_keys = set()
|
|
126
|
+
self.bind("<KeyPress>", self.on_key_press)
|
|
127
|
+
|
|
128
|
+
# The following commands keep the popup on top.
|
|
129
|
+
# Remove these if you want a program with 2 responding windows.
|
|
130
|
+
# These commands must be at the end of __init__
|
|
131
|
+
self.transient(master) # set to be on top of the main window
|
|
132
|
+
self.grab_set() # hijack all commands from the master (clicks on the main window are ignored)
|
|
133
|
+
master.wait_window(
|
|
134
|
+
self
|
|
135
|
+
) # pause anything on the main window until this one closes
|
|
136
|
+
|
|
137
|
+
def on_save(self):
|
|
138
|
+
|
|
139
|
+
selected_buttons = []
|
|
140
|
+
target = self.var_command.get()
|
|
141
|
+
name = self.entry_name.get()
|
|
142
|
+
|
|
143
|
+
for btn, var in self.var_buttons.items():
|
|
144
|
+
if var.get() == 1:
|
|
145
|
+
selected_buttons.append(button_mapper[btn])
|
|
146
|
+
|
|
147
|
+
selected_d_pad_index = self.var_d_pad.get()
|
|
148
|
+
if selected_d_pad_index == -1:
|
|
149
|
+
d_pad_action = (0, 0)
|
|
150
|
+
else:
|
|
151
|
+
d_pad_action = list(d_pad_mapper.values())[selected_d_pad_index]
|
|
152
|
+
|
|
153
|
+
error = None
|
|
154
|
+
if len(selected_buttons) == 0 and d_pad_action == (0, 0):
|
|
155
|
+
error = "No controller shortcut was selected!"
|
|
156
|
+
elif target == "" or target == self.entry_target_command.placeholder:
|
|
157
|
+
error = "No target command was specified!"
|
|
158
|
+
elif name == "" or name == self.entry_name.placeholder:
|
|
159
|
+
error = "The name of the shortcut was not set!"
|
|
160
|
+
if error:
|
|
161
|
+
messagebox.showerror("Error", error)
|
|
162
|
+
return
|
|
163
|
+
|
|
164
|
+
state = ControllerState(
|
|
165
|
+
active_buttons=selected_buttons, d_pad_state=d_pad_action
|
|
166
|
+
) # save the return value to an instance variable.
|
|
167
|
+
self.result = Mapping(
|
|
168
|
+
action_type=ActionType(self.var_action_type.get()),
|
|
169
|
+
target=target,
|
|
170
|
+
controller_state=state,
|
|
171
|
+
name=name,
|
|
172
|
+
)
|
|
173
|
+
|
|
174
|
+
self.destroy()
|
|
175
|
+
|
|
176
|
+
def action_type_changed(self, _=None):
|
|
177
|
+
self.action_type = ActionType(self.var_action_type.get())
|
|
178
|
+
|
|
179
|
+
if self.action_type == ActionType.TASK_KILL_BY_NAME:
|
|
180
|
+
self.entry_target_command.set_placeholder(
|
|
181
|
+
"name of task to kill (e.g. explorer.exe)"
|
|
182
|
+
)
|
|
183
|
+
elif self.action_type == ActionType.KEYBOARD_SHORTCUT:
|
|
184
|
+
self.pressed_keys.clear()
|
|
185
|
+
self.entry_target_command.set_placeholder("click here, then enter shortcut")
|
|
186
|
+
else:
|
|
187
|
+
self.entry_target_command.set_placeholder(
|
|
188
|
+
"custom arbitrary console command"
|
|
189
|
+
)
|
|
190
|
+
|
|
191
|
+
def on_key_press(self, event: Event):
|
|
192
|
+
valid_keys = Mapping.get_valid_keyboard_keys()
|
|
193
|
+
if self.action_type == ActionType.KEYBOARD_SHORTCUT:
|
|
194
|
+
key = event.char if event.char in valid_keys else event.keysym
|
|
195
|
+
key = (
|
|
196
|
+
key.lower()
|
|
197
|
+
.replace("_l", "")
|
|
198
|
+
.replace("_r", "")
|
|
199
|
+
.replace("control", "ctrl")
|
|
200
|
+
.replace("prior", "pageup")
|
|
201
|
+
.replace("next", "pagedown")
|
|
202
|
+
)
|
|
203
|
+
# XF86AudioLowerVolume
|
|
204
|
+
if "audiolower" in key:
|
|
205
|
+
key = "volumedown"
|
|
206
|
+
# XF86AudioRaiseVolume
|
|
207
|
+
elif "audioraise" in key:
|
|
208
|
+
key = "volumeup"
|
|
209
|
+
# XF86AudioMute
|
|
210
|
+
elif "audiomute" in key:
|
|
211
|
+
key = "volumemute"
|
|
212
|
+
elif key == "\t":
|
|
213
|
+
key = "tab"
|
|
214
|
+
|
|
215
|
+
if not key in valid_keys:
|
|
216
|
+
logger.warning(f"Unknown key event {event}")
|
|
217
|
+
return
|
|
218
|
+
|
|
219
|
+
if self.entry_target_command.focused:
|
|
220
|
+
if key in ["escape", "backspace"]:
|
|
221
|
+
self.pressed_keys.clear()
|
|
222
|
+
elif key != "+":
|
|
223
|
+
self.pressed_keys.add(key)
|
|
224
|
+
|
|
225
|
+
# sort keys
|
|
226
|
+
sorted_keys = list(self.pressed_keys)
|
|
227
|
+
ranks = ["ctrl", "win", "command", "alt", "shift"]
|
|
228
|
+
# 24 f keys supported by PyAutoGUI
|
|
229
|
+
f_keys = [f"f{i}" for i in range(1, 25)]
|
|
230
|
+
ranks.extend(f_keys)
|
|
231
|
+
sorted_keys.sort(
|
|
232
|
+
key=lambda k: (
|
|
233
|
+
str(ranks.index(k))
|
|
234
|
+
if k in ranks
|
|
235
|
+
else f"_{list(self.pressed_keys).index(k)}"
|
|
236
|
+
)
|
|
237
|
+
)
|
|
238
|
+
|
|
239
|
+
self.entry_target_command.set_text("+".join(sorted_keys))
|
|
240
|
+
return "break"
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
import sys
|
|
3
|
+
from typing import Union
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def __get_resource_path(relative_path: Union[Path, str]) -> Path:
|
|
7
|
+
"""Get correct path to resource, works for dev and for executables.
|
|
8
|
+
|
|
9
|
+
Args:
|
|
10
|
+
relative_path (str): Path relative to the project root.
|
|
11
|
+
|
|
12
|
+
Returns:
|
|
13
|
+
Path: Full path to the requested resource.
|
|
14
|
+
"""
|
|
15
|
+
# for executables, _MEIPASS is set, in dev mode we will use the repo root.
|
|
16
|
+
base_path = getattr(sys, "_MEIPASS", "")
|
|
17
|
+
return Path(base_path, relative_path)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def is_frozen() -> bool:
|
|
21
|
+
if getattr(sys, "frozen", False):
|
|
22
|
+
# we are running in a bundle
|
|
23
|
+
return True
|
|
24
|
+
return False
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def get_executable_path() -> Path:
|
|
28
|
+
return Path(sys.executable)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
APP_ICON_ICO = __get_resource_path("controller_companion/res/app.ico")
|
|
32
|
+
APP_ICON_PNG = __get_resource_path("controller_companion/res/app.png")
|
|
File without changes
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import tkinter as tk
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class PlaceholderEntry(tk.Entry):
|
|
5
|
+
|
|
6
|
+
def __init__(self, master=None, placeholder: str = "", **kwargs):
|
|
7
|
+
super().__init__(master, **kwargs)
|
|
8
|
+
self.placeholder = placeholder
|
|
9
|
+
self.insert(0, self.placeholder)
|
|
10
|
+
self.configure(foreground="gray")
|
|
11
|
+
self.bind("<FocusIn>", self.__on_entry_click)
|
|
12
|
+
self.bind("<FocusOut>", self.__on_focus_out)
|
|
13
|
+
self.focused = False
|
|
14
|
+
|
|
15
|
+
def __on_entry_click(self, _=None):
|
|
16
|
+
if self.get() == self.placeholder:
|
|
17
|
+
self.delete(0, tk.END)
|
|
18
|
+
self.configure(foreground="black")
|
|
19
|
+
self.focused = True
|
|
20
|
+
|
|
21
|
+
def __on_focus_out(self, _=None):
|
|
22
|
+
if self.get() == "":
|
|
23
|
+
self.insert(0, self.placeholder)
|
|
24
|
+
self.configure(foreground="gray")
|
|
25
|
+
self.focused = False
|
|
26
|
+
|
|
27
|
+
def set_placeholder(self, placeholder: str):
|
|
28
|
+
if self.get() == self.placeholder:
|
|
29
|
+
self.delete(0, tk.END)
|
|
30
|
+
self.insert(0, placeholder)
|
|
31
|
+
|
|
32
|
+
self.placeholder = placeholder
|
|
33
|
+
|
|
34
|
+
def set_text(self, text: str):
|
|
35
|
+
self.delete(0, tk.END)
|
|
36
|
+
self.insert(0, text)
|
|
37
|
+
self.__on_entry_click()
|
|
@@ -0,0 +1,301 @@
|
|
|
1
|
+
import argparse
|
|
2
|
+
import os
|
|
3
|
+
import threading
|
|
4
|
+
import traceback
|
|
5
|
+
from typing import Callable, Dict, List
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
import controller_companion
|
|
9
|
+
from controller_companion.logs import logger
|
|
10
|
+
from controller_companion import logs
|
|
11
|
+
|
|
12
|
+
# import pygame, hide welcome message
|
|
13
|
+
os.environ["PYGAME_HIDE_SUPPORT_PROMPT"] = "yes"
|
|
14
|
+
import pygame
|
|
15
|
+
from rich.table import Table
|
|
16
|
+
from rich.console import Console
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
from controller_companion.mapping import Mapping, ActionType
|
|
20
|
+
from controller_companion.controller import Controller
|
|
21
|
+
from controller_companion.controller_state import (
|
|
22
|
+
ControllerState,
|
|
23
|
+
button_mapper,
|
|
24
|
+
d_pad_mapper,
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
VALID_KEYBOARD_KEYS = list(button_mapper.keys()) + list(d_pad_mapper.keys())
|
|
29
|
+
do_run = True
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def start_observer(
|
|
33
|
+
defined_actions: List[Mapping],
|
|
34
|
+
debug: bool = False,
|
|
35
|
+
controller_callback: Callable[[List[Controller]], None] = None,
|
|
36
|
+
restart_delay_ms: int = 1000,
|
|
37
|
+
):
|
|
38
|
+
|
|
39
|
+
if debug:
|
|
40
|
+
logs.set_log_level(logs.DEBUG)
|
|
41
|
+
else:
|
|
42
|
+
logs.set_log_level(logs.INFO)
|
|
43
|
+
|
|
44
|
+
# ------------------- print the defined mappings in a table ------------------ #
|
|
45
|
+
if len(defined_actions) > 0:
|
|
46
|
+
table = Table(title="Defined Mappings")
|
|
47
|
+
table.add_column("Name", justify="left", style="blue", header_style="blue")
|
|
48
|
+
table.add_column(
|
|
49
|
+
"Shortcut", justify="left", style="magenta", header_style="magenta"
|
|
50
|
+
)
|
|
51
|
+
table.add_column("Action", justify="left", style="green", header_style="green")
|
|
52
|
+
|
|
53
|
+
for mapping in defined_actions:
|
|
54
|
+
table.add_row(
|
|
55
|
+
mapping.name,
|
|
56
|
+
mapping.controller_state.describe(),
|
|
57
|
+
mapping.target,
|
|
58
|
+
)
|
|
59
|
+
Console().log(table)
|
|
60
|
+
else:
|
|
61
|
+
logger.info("No mappings have been defined.")
|
|
62
|
+
# ---------------------------------------------------------------------------- #
|
|
63
|
+
|
|
64
|
+
logger.info("Listening to controller inputs.")
|
|
65
|
+
|
|
66
|
+
try:
|
|
67
|
+
__process_pygame_events(
|
|
68
|
+
defined_actions=defined_actions,
|
|
69
|
+
controller_callback=controller_callback,
|
|
70
|
+
)
|
|
71
|
+
except Exception:
|
|
72
|
+
logger.error(
|
|
73
|
+
f"An exception occurred inside __process_pygame_events:\n{traceback.format_exc()}"
|
|
74
|
+
)
|
|
75
|
+
logger.info(f"restarting controller observation in {restart_delay_ms}s")
|
|
76
|
+
pygame.time.wait(restart_delay_ms)
|
|
77
|
+
return start_observer(
|
|
78
|
+
defined_actions=defined_actions,
|
|
79
|
+
debug=debug,
|
|
80
|
+
controller_callback=controller_callback,
|
|
81
|
+
restart_delay_ms=restart_delay_ms,
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
pygame.quit()
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def __process_pygame_events(
|
|
88
|
+
defined_actions: Dict[str, Mapping] = {},
|
|
89
|
+
controller_callback: Callable[[List[Controller]], None] = None,
|
|
90
|
+
):
|
|
91
|
+
pygame.init()
|
|
92
|
+
pygame.joystick.init()
|
|
93
|
+
|
|
94
|
+
t = threading.current_thread()
|
|
95
|
+
|
|
96
|
+
# Initialize all detected joysticks
|
|
97
|
+
# Apparently with more than one controller connected this is required for all controllers to raise button/ d-pad events
|
|
98
|
+
# even though they will also raise an pygame.JOYDEVICEADDED event right from the start.
|
|
99
|
+
for i in range(pygame.joystick.get_count()):
|
|
100
|
+
pygame.joystick.Joystick(i).init()
|
|
101
|
+
|
|
102
|
+
controllers: Dict[int, pygame.joystick.JoystickType] = {}
|
|
103
|
+
controller_states: Dict[int, ControllerState] = {}
|
|
104
|
+
|
|
105
|
+
while getattr(t, "do_run", True):
|
|
106
|
+
for event in pygame.event.get():
|
|
107
|
+
instance_id = event.dict.get("instance_id", None)
|
|
108
|
+
|
|
109
|
+
if event.type in [pygame.JOYBUTTONDOWN, pygame.JOYBUTTONUP]:
|
|
110
|
+
button = event.dict["button"]
|
|
111
|
+
|
|
112
|
+
active_buttons = controller_states[instance_id].active_buttons
|
|
113
|
+
if event.type == pygame.JOYBUTTONDOWN:
|
|
114
|
+
active_buttons.append(button)
|
|
115
|
+
else:
|
|
116
|
+
active_buttons.remove(button)
|
|
117
|
+
elif event.type == pygame.JOYHATMOTION:
|
|
118
|
+
controller_states[instance_id].d_pad_state = event.dict["value"]
|
|
119
|
+
elif event.type in [pygame.JOYDEVICEADDED, pygame.JOYDEVICEREMOVED]:
|
|
120
|
+
if event.type == pygame.JOYDEVICEADDED:
|
|
121
|
+
logger.info(f"Controller connected: {event}")
|
|
122
|
+
c = pygame.joystick.Joystick(event.device_index)
|
|
123
|
+
c.init()
|
|
124
|
+
instance_id = c.get_instance_id()
|
|
125
|
+
name = c.get_name()
|
|
126
|
+
|
|
127
|
+
controllers[instance_id] = Controller(
|
|
128
|
+
# on windows the controller name is wrapped inside "Controller()" when connected via USB
|
|
129
|
+
name=name.removeprefix("Controller (").removesuffix(")"),
|
|
130
|
+
guid=c.get_guid(),
|
|
131
|
+
power_level=c.get_power_level(),
|
|
132
|
+
instance_id=c.get_instance_id(),
|
|
133
|
+
initialized=c.get_init(),
|
|
134
|
+
)
|
|
135
|
+
controller_states[instance_id] = ControllerState()
|
|
136
|
+
else:
|
|
137
|
+
logger.info(f"Controller removed: {event}")
|
|
138
|
+
controllers.pop(instance_id)
|
|
139
|
+
controller_states.pop(instance_id)
|
|
140
|
+
|
|
141
|
+
if controller_callback:
|
|
142
|
+
# call the callback through a thread so it does not keep the observer waiting (e.g. app in background)
|
|
143
|
+
threading.Thread(
|
|
144
|
+
target=controller_callback, args=[list(controllers.values())]
|
|
145
|
+
).start()
|
|
146
|
+
else:
|
|
147
|
+
# skip all other events. this way only relevant updates are processed below.
|
|
148
|
+
# this is relevant as e.g. thumbstick updates spam lots of updates
|
|
149
|
+
continue
|
|
150
|
+
__check_for_mappings(controller_states, defined_actions)
|
|
151
|
+
|
|
152
|
+
logger.debug(f"Controller state changed: {controller_states}")
|
|
153
|
+
pygame.time.wait(250)
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def __check_for_mappings(
|
|
157
|
+
controller_states: Dict[int, ControllerState],
|
|
158
|
+
defined_actions: List[Mapping],
|
|
159
|
+
):
|
|
160
|
+
"""Checks if one of the current controller states matches a defined mapping.
|
|
161
|
+
|
|
162
|
+
Args:
|
|
163
|
+
controller_states (Dict[int, ControllerState]): Dict of all current controller states where the key is the instance-id.
|
|
164
|
+
defined_actions (List[Mapping]): List of defined mappings.
|
|
165
|
+
"""
|
|
166
|
+
for instance_id, state in controller_states.items():
|
|
167
|
+
for action in defined_actions:
|
|
168
|
+
if action.controller_state.describe() == state.describe():
|
|
169
|
+
logger.info(f"Mapping detected: {action} on controller {instance_id}")
|
|
170
|
+
action.execute()
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def cli():
|
|
174
|
+
parser = argparse.ArgumentParser(description="Controller Companion.")
|
|
175
|
+
parser.add_argument(
|
|
176
|
+
"-t",
|
|
177
|
+
"--task_kill",
|
|
178
|
+
help="Kill tasks by their name.",
|
|
179
|
+
nargs="+",
|
|
180
|
+
type=str,
|
|
181
|
+
default=[],
|
|
182
|
+
)
|
|
183
|
+
parser.add_argument(
|
|
184
|
+
"-c",
|
|
185
|
+
"--console",
|
|
186
|
+
help="Execute console commands.",
|
|
187
|
+
nargs="+",
|
|
188
|
+
type=str,
|
|
189
|
+
default=[],
|
|
190
|
+
)
|
|
191
|
+
parser.add_argument(
|
|
192
|
+
"-s",
|
|
193
|
+
"--shortcut",
|
|
194
|
+
help='Keyboard shortcut, where each shortcut is defined by a number of keys separated by "+" (e.g. "alt+f4").',
|
|
195
|
+
nargs="+",
|
|
196
|
+
type=str,
|
|
197
|
+
default=[],
|
|
198
|
+
)
|
|
199
|
+
parser.add_argument(
|
|
200
|
+
"-i",
|
|
201
|
+
"--input",
|
|
202
|
+
help="Input controller button combination.",
|
|
203
|
+
nargs="+",
|
|
204
|
+
type=str,
|
|
205
|
+
)
|
|
206
|
+
parser.add_argument(
|
|
207
|
+
"--valid-keys",
|
|
208
|
+
help="List all valid keys.",
|
|
209
|
+
action="store_true",
|
|
210
|
+
default=False,
|
|
211
|
+
)
|
|
212
|
+
parser.add_argument(
|
|
213
|
+
"-d",
|
|
214
|
+
"--debug",
|
|
215
|
+
help="Enable debug messages.",
|
|
216
|
+
action="store_true",
|
|
217
|
+
default=False,
|
|
218
|
+
)
|
|
219
|
+
parser.add_argument(
|
|
220
|
+
"-v",
|
|
221
|
+
"--version",
|
|
222
|
+
help="Print the installed version of this library.",
|
|
223
|
+
action="store_true",
|
|
224
|
+
default=False,
|
|
225
|
+
)
|
|
226
|
+
args = parser.parse_args()
|
|
227
|
+
debug = args.debug
|
|
228
|
+
defined_actions = []
|
|
229
|
+
|
|
230
|
+
if args.version:
|
|
231
|
+
print("Installed version:", controller_companion.VERSION)
|
|
232
|
+
return
|
|
233
|
+
elif args.valid_keys:
|
|
234
|
+
print(
|
|
235
|
+
f"The following keys are valid inputs that can be used with the --shortcut argument:\n{Mapping.get_valid_keyboard_keys()}"
|
|
236
|
+
)
|
|
237
|
+
return
|
|
238
|
+
|
|
239
|
+
if args.input is not None:
|
|
240
|
+
if len(args.input) != (
|
|
241
|
+
len(args.task_kill) + len(args.console) + len(args.shortcut)
|
|
242
|
+
):
|
|
243
|
+
raise Exception(
|
|
244
|
+
"Length of --mapping needs to match with combined sum of commands provided to --task_kill, --console and --shortcut"
|
|
245
|
+
)
|
|
246
|
+
|
|
247
|
+
states = []
|
|
248
|
+
for m in args.input:
|
|
249
|
+
keys = m.split(",")
|
|
250
|
+
buttons = []
|
|
251
|
+
d_pad = (0, 0)
|
|
252
|
+
for input in keys:
|
|
253
|
+
if input in button_mapper:
|
|
254
|
+
buttons.append(button_mapper[input])
|
|
255
|
+
elif input in d_pad_mapper:
|
|
256
|
+
d_pad = d_pad_mapper[input]
|
|
257
|
+
else:
|
|
258
|
+
raise Exception(
|
|
259
|
+
f"key {input} is not a valid input. Valid options are {VALID_KEYBOARD_KEYS}"
|
|
260
|
+
)
|
|
261
|
+
states.append(ControllerState(buttons, d_pad))
|
|
262
|
+
|
|
263
|
+
state_counter = 0
|
|
264
|
+
for t in args.task_kill:
|
|
265
|
+
defined_actions.append(
|
|
266
|
+
Mapping(
|
|
267
|
+
ActionType.TASK_KILL_BY_NAME,
|
|
268
|
+
target=t,
|
|
269
|
+
name=f'Kill "{t}"',
|
|
270
|
+
controller_state=states[state_counter],
|
|
271
|
+
)
|
|
272
|
+
)
|
|
273
|
+
state_counter += 1
|
|
274
|
+
|
|
275
|
+
for c in args.console:
|
|
276
|
+
defined_actions.append(
|
|
277
|
+
Mapping(
|
|
278
|
+
ActionType.CONSOLE_COMMAND,
|
|
279
|
+
target=c,
|
|
280
|
+
name=f'Run command "{c}"',
|
|
281
|
+
controller_state=states[state_counter],
|
|
282
|
+
)
|
|
283
|
+
)
|
|
284
|
+
state_counter += 1
|
|
285
|
+
|
|
286
|
+
for s in args.shortcut:
|
|
287
|
+
defined_actions.append(
|
|
288
|
+
Mapping(
|
|
289
|
+
ActionType.KEYBOARD_SHORTCUT,
|
|
290
|
+
target=s,
|
|
291
|
+
name=f'Shortcut "{s}"',
|
|
292
|
+
controller_state=states[state_counter],
|
|
293
|
+
)
|
|
294
|
+
)
|
|
295
|
+
state_counter += 1
|
|
296
|
+
|
|
297
|
+
start_observer(defined_actions=defined_actions, debug=debug)
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
if __name__ == "__main__":
|
|
301
|
+
cli()
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
from typing import Dict, List, Optional, Tuple
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
button_mapper = {
|
|
5
|
+
"A": 0,
|
|
6
|
+
"B": 1,
|
|
7
|
+
"X": 2,
|
|
8
|
+
"Y": 3,
|
|
9
|
+
"LB": 4,
|
|
10
|
+
"RB": 5,
|
|
11
|
+
"Back": 6,
|
|
12
|
+
"Start": 7,
|
|
13
|
+
"L-Stick": 8,
|
|
14
|
+
"R-Stick": 9,
|
|
15
|
+
"X-Box": 10,
|
|
16
|
+
}
|
|
17
|
+
button_mapper_inv = {v: k for k, v in button_mapper.items()}
|
|
18
|
+
|
|
19
|
+
d_pad_mapper = {
|
|
20
|
+
"Left": (-1, 0),
|
|
21
|
+
"Right": (1, 0),
|
|
22
|
+
"Up": (0, 1),
|
|
23
|
+
"Down": (0, -1),
|
|
24
|
+
"Left-Down": (-1, -1),
|
|
25
|
+
"Left-Up": (-1, 1),
|
|
26
|
+
"Right-Up": (1, 1),
|
|
27
|
+
"Right-Down": (1, -1),
|
|
28
|
+
}
|
|
29
|
+
d_pad_mapper_inv = {v: k for k, v in d_pad_mapper.items()}
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class ControllerState:
|
|
33
|
+
|
|
34
|
+
def __init__(
|
|
35
|
+
self,
|
|
36
|
+
active_buttons: Optional[List[int]] = None,
|
|
37
|
+
d_pad_state: Tuple[int, int] = (0, 0),
|
|
38
|
+
):
|
|
39
|
+
self.active_buttons: List[int] = active_buttons if active_buttons else []
|
|
40
|
+
self.d_pad_state: Tuple[int, int] = d_pad_state
|
|
41
|
+
|
|
42
|
+
def __repr__(self):
|
|
43
|
+
return self.__str__()
|
|
44
|
+
|
|
45
|
+
def __str__(self):
|
|
46
|
+
self.active_buttons.sort()
|
|
47
|
+
return f"<ControllerState active_buttons: {self.active_buttons}, d_pad_action: {self.d_pad_state}>"
|
|
48
|
+
|
|
49
|
+
def describe(self) -> str:
|
|
50
|
+
self.active_buttons.sort()
|
|
51
|
+
s = ",".join([button_mapper_inv[button] for button in self.active_buttons])
|
|
52
|
+
d_pad_action_description = d_pad_mapper_inv.get(self.d_pad_state, None)
|
|
53
|
+
s += (
|
|
54
|
+
"," + d_pad_action_description
|
|
55
|
+
if d_pad_action_description is not None
|
|
56
|
+
else ""
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
return f'<{s.removeprefix(",")}>'
|
|
60
|
+
|
|
61
|
+
def to_dict(self):
|
|
62
|
+
return {
|
|
63
|
+
"active_buttons": self.active_buttons,
|
|
64
|
+
"d_pad_state": self.d_pad_state,
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
@classmethod
|
|
68
|
+
def from_dict(cls, dict: Dict):
|
|
69
|
+
|
|
70
|
+
return cls(
|
|
71
|
+
active_buttons=dict["active_buttons"],
|
|
72
|
+
# tuples become lists during json conversion
|
|
73
|
+
d_pad_state=tuple(dict["d_pad_state"]),
|
|
74
|
+
)
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
from rich.logging import RichHandler
|
|
3
|
+
|
|
4
|
+
DEBUG = logging.DEBUG
|
|
5
|
+
INFO = logging.INFO
|
|
6
|
+
WARNING = logging.WARNING
|
|
7
|
+
ERROR = logging.ERROR
|
|
8
|
+
|
|
9
|
+
FORMAT = "%(message)s"
|
|
10
|
+
logging.basicConfig(format=FORMAT, datefmt="[%X]", handlers=[RichHandler()])
|
|
11
|
+
logger = logging.getLogger(__name__)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def set_log_level(level):
|
|
15
|
+
logger.setLevel(level)
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
from enum import Enum
|
|
2
|
+
import subprocess
|
|
3
|
+
from typing import Dict, List
|
|
4
|
+
import pyautogui
|
|
5
|
+
from controller_companion.controller_state import ControllerState
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class ActionType(Enum):
|
|
9
|
+
TASK_KILL_BY_NAME = "Kill by Name"
|
|
10
|
+
CONSOLE_COMMAND = "Console Command"
|
|
11
|
+
KEYBOARD_SHORTCUT = "Keyboard Shortcut"
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class Mapping:
|
|
15
|
+
|
|
16
|
+
def __init__(
|
|
17
|
+
self,
|
|
18
|
+
action_type: ActionType,
|
|
19
|
+
target: str,
|
|
20
|
+
controller_state: ControllerState,
|
|
21
|
+
name: str,
|
|
22
|
+
):
|
|
23
|
+
self.name = name
|
|
24
|
+
self.action_type = action_type
|
|
25
|
+
self.target = target
|
|
26
|
+
self.controller_state = controller_state
|
|
27
|
+
|
|
28
|
+
def execute(self):
|
|
29
|
+
if self.action_type == ActionType.TASK_KILL_BY_NAME:
|
|
30
|
+
subprocess.run(["taskkill", "/im", self.target])
|
|
31
|
+
elif self.action_type == ActionType.KEYBOARD_SHORTCUT:
|
|
32
|
+
keys = self.target.split("+")
|
|
33
|
+
|
|
34
|
+
invalid_keys = [k for k in keys if not pyautogui.isValidKey(k)]
|
|
35
|
+
if invalid_keys:
|
|
36
|
+
print(
|
|
37
|
+
f"Invalid keys provided as keyboard shortcuts! The following keys are invalid: {invalid_keys}"
|
|
38
|
+
)
|
|
39
|
+
return
|
|
40
|
+
|
|
41
|
+
pyautogui.hotkey(keys)
|
|
42
|
+
else:
|
|
43
|
+
subprocess.run(self.target)
|
|
44
|
+
|
|
45
|
+
def __str__(self):
|
|
46
|
+
return f"Controller{self.controller_state.describe()} --> Action<name: {self.name}, target: {self.target}, type: {self.action_type.name}>"
|
|
47
|
+
|
|
48
|
+
def to_dict(self):
|
|
49
|
+
return {
|
|
50
|
+
"name": self.name,
|
|
51
|
+
"action_type": self.action_type.name,
|
|
52
|
+
"target": self.target,
|
|
53
|
+
"controller_state": self.controller_state.to_dict(),
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
@classmethod
|
|
57
|
+
def from_dict(cls, dict: Dict):
|
|
58
|
+
|
|
59
|
+
return cls(
|
|
60
|
+
name=dict["name"],
|
|
61
|
+
target=dict["target"],
|
|
62
|
+
action_type=ActionType[dict["action_type"]],
|
|
63
|
+
controller_state=ControllerState.from_dict(dict["controller_state"]),
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
def get_valid_keyboard_keys() -> List[str]:
|
|
67
|
+
return pyautogui.KEYBOARD_KEYS
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import platform
|
|
2
|
+
import shutil
|
|
3
|
+
import PyInstaller.__main__
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
from controller_companion import VERSION
|
|
7
|
+
from controller_companion.app import resources
|
|
8
|
+
|
|
9
|
+
HERE = Path(__file__).parent.absolute()
|
|
10
|
+
path_to_main = str(HERE / "app/app.py")
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def install():
|
|
14
|
+
|
|
15
|
+
PyInstaller.__main__.run(
|
|
16
|
+
[
|
|
17
|
+
path_to_main,
|
|
18
|
+
"--name",
|
|
19
|
+
"controller-companion",
|
|
20
|
+
"--windowed",
|
|
21
|
+
"--add-data",
|
|
22
|
+
"controller_companion/res:controller_companion/res",
|
|
23
|
+
"--icon",
|
|
24
|
+
str(resources.APP_ICON_ICO),
|
|
25
|
+
# remove question to override an existing output folder
|
|
26
|
+
"--noconfirm",
|
|
27
|
+
]
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
# zip the pyinstaller output as an artifact
|
|
31
|
+
os_name = platform.system().replace("Darwin", "Mac").lower()
|
|
32
|
+
output_path = f"dist/controller-companion-{VERSION}-{os_name}"
|
|
33
|
+
shutil.make_archive(output_path, "zip", "dist/controller-companion")
|
|
Binary file
|
|
Binary file
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
[tool.poetry]
|
|
2
|
+
name = "controller-companion"
|
|
3
|
+
version = "0.4.0"
|
|
4
|
+
description = ""
|
|
5
|
+
authors = [
|
|
6
|
+
"Johannes Gundlach <24914225+Johannes11833@users.noreply.github.com>",
|
|
7
|
+
]
|
|
8
|
+
readme = "README.md"
|
|
9
|
+
|
|
10
|
+
[tool.poetry.dependencies]
|
|
11
|
+
python = ">=3.9,<3.14"
|
|
12
|
+
pygame = "^2.6.1"
|
|
13
|
+
pystray = "^0.19.5"
|
|
14
|
+
pillow = "^11.0.0"
|
|
15
|
+
pyautogui = "^0.9.54"
|
|
16
|
+
requests = "^2.32.3"
|
|
17
|
+
rich = "^13.9.4"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
[tool.poetry.group.dev.dependencies]
|
|
21
|
+
pyinstaller = "^6.11.1"
|
|
22
|
+
|
|
23
|
+
[build-system]
|
|
24
|
+
requires = ["poetry-core"]
|
|
25
|
+
build-backend = "poetry.core.masonry.api"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
[tool.poetry.scripts]
|
|
29
|
+
controller_companion = "controller_companion.controller_observer:cli"
|
|
30
|
+
controller_companion_ui = "controller_companion.app.app:cli"
|
|
31
|
+
build = "controller_companion.pyinstaller:install"
|