controller-companion 0.4.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,4 @@
1
+ from importlib.metadata import version
2
+
3
+
4
+ VERSION = f'v{version("controller-companion")}'
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,10 @@
1
+ from dataclasses import dataclass
2
+
3
+
4
+ @dataclass
5
+ class Controller:
6
+ name: str
7
+ guid: str
8
+ power_level: str
9
+ instance_id: str
10
+ initialized: bool
@@ -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,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
+ ![demo](demo/app.png)
64
+ ### Create a new shortcut
65
+ ![demo](demo/add_new_shortcut.png)
66
+
@@ -0,0 +1,21 @@
1
+ controller_companion/__init__.py,sha256=SGGTgydNSrfp9XhczBO2iEKLFIDx8wdMiZLJrwvNJ-Q,89
2
+ controller_companion/app/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
3
+ controller_companion/app/app.py,sha256=-hZi22Sji0rtOtpbfAzoNsdplMBQXEyQxW8RdsMeuUo,10653
4
+ controller_companion/app/popup_about.py,sha256=JH_6VI2WlD04mkwXHownh5EC3ulu9vEM_MUqXkJkhiA,1530
5
+ controller_companion/app/popup_create_action.py,sha256=1K8Z6ZeDzxX9KCSeDuxCSmdmFjo8wq9rHSKvgwiU0eA,8452
6
+ controller_companion/app/resources.py,sha256=3OlmRV0XrJN9ue7dYFuZd23PF1gcX1ySGlyTdzNqv-Q,867
7
+ controller_companion/app/widgets/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
8
+ controller_companion/app/widgets/placeholder_entry.py,sha256=6m-rkIvm2eUJ01gebZra5n69F5T_NogC_p2JgEOpTvY,1147
9
+ controller_companion/controller.py,sha256=XWgxgoUHBPgSb-dUvLo7kNzR4SVYrmq2ZX7ikZk8FbA,157
10
+ controller_companion/controller_observer.py,sha256=_p7zrB3aWSWGmJPQV9oAAYO9D56JL-XRWebMcp7MTc8,10177
11
+ controller_companion/controller_state.py,sha256=PvND1u4W5BHKHkDK9nagkXXIlq8hihjbJffA9fL77gI,1880
12
+ controller_companion/logs.py,sha256=MSMo-YVWA_z7qFeCEJcfGOE9i7El3kEIjYU-OhS_8Dg,336
13
+ controller_companion/mapping.py,sha256=JueY0E0X6kfVFRx7qRgVMDBP8lP-DMzjqGL734iLhJI,2024
14
+ controller_companion/pyinstaller.py,sha256=CXdkyO-Z6jQOAcKFm2ICjOhlRs1P8o7jyBeem7qdd_g,940
15
+ controller_companion/res/app.ico,sha256=zgleNgPFoJB1iYiHK9Yif4XrIhP5OqScf60mBsPj0_4,10462
16
+ controller_companion/res/app.png,sha256=ZaIzZhQ93AEtqhNiGl7mFX8OZpFD2TTVSOG8jrQP9KU,8258
17
+ controller_companion-0.4.0.dist-info/LICENSE,sha256=Q40JPZbkkzk46C-J6BvZ3OsT2Cy1vBazyJWPey0q9us,1074
18
+ controller_companion-0.4.0.dist-info/METADATA,sha256=xHJecgU1WkEQZplh1mEhDQrNzjbZ5c6xdEWb63FSRAY,2790
19
+ controller_companion-0.4.0.dist-info/WHEEL,sha256=Nq82e9rUAnEjt98J6MlVmMCZb-t9cYE2Ir1kpBmnWfs,88
20
+ controller_companion-0.4.0.dist-info/entry_points.txt,sha256=ku6zVOZbEjNoGgqzs3YIWggW2_6yLrSuUYih4X3UJ0A,189
21
+ controller_companion-0.4.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: poetry-core 1.9.1
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,5 @@
1
+ [console_scripts]
2
+ build=controller_companion.pyinstaller:install
3
+ controller_companion=controller_companion.controller_observer:cli
4
+ controller_companion_ui=controller_companion.app.app:cli
5
+