py-global-shortcuts 0.1.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.
- py_global_shortcuts-0.1.0/PKG-INFO +10 -0
- py_global_shortcuts-0.1.0/README.md +2 -0
- py_global_shortcuts-0.1.0/py_global_shortcuts/__init__.py +76 -0
- py_global_shortcuts-0.1.0/py_global_shortcuts/filed_dict.py +114 -0
- py_global_shortcuts-0.1.0/py_global_shortcuts/globals.py +9 -0
- py_global_shortcuts-0.1.0/py_global_shortcuts/gnome_binder.py +134 -0
- py_global_shortcuts-0.1.0/py_global_shortcuts/linux_comms.py +88 -0
- py_global_shortcuts-0.1.0/py_global_shortcuts/pynput_binder.py +52 -0
- py_global_shortcuts-0.1.0/py_global_shortcuts/shortcut_binder.py +179 -0
- py_global_shortcuts-0.1.0/py_global_shortcuts/utilities.py +37 -0
- py_global_shortcuts-0.1.0/py_global_shortcuts.egg-info/PKG-INFO +10 -0
- py_global_shortcuts-0.1.0/py_global_shortcuts.egg-info/SOURCES.txt +15 -0
- py_global_shortcuts-0.1.0/py_global_shortcuts.egg-info/dependency_links.txt +1 -0
- py_global_shortcuts-0.1.0/py_global_shortcuts.egg-info/requires.txt +1 -0
- py_global_shortcuts-0.1.0/py_global_shortcuts.egg-info/top_level.txt +1 -0
- py_global_shortcuts-0.1.0/pyproject.toml +9 -0
- py_global_shortcuts-0.1.0/setup.cfg +4 -0
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: py-global-shortcuts
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Python library for creating global keyboard shortcuts.
|
|
5
|
+
Requires-Python: >=3.10
|
|
6
|
+
Description-Content-Type: text/markdown
|
|
7
|
+
Requires-Dist: pynput>=1.8.1
|
|
8
|
+
|
|
9
|
+
# py-global-shortcuts
|
|
10
|
+
Global shortcuts for python apps using GNOME and Wayland
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import platform
|
|
2
|
+
import sys
|
|
3
|
+
from . import globals as g
|
|
4
|
+
from . import shortcut_binder as sb
|
|
5
|
+
from . import utilities as u
|
|
6
|
+
from . shortcut_binder import Command, KeyBinding, KeyBinder
|
|
7
|
+
|
|
8
|
+
_binder: sb.KeyBinder = None
|
|
9
|
+
_comms = None
|
|
10
|
+
|
|
11
|
+
def init(appname: str, cache_dir="./temp"):
|
|
12
|
+
import os
|
|
13
|
+
|
|
14
|
+
if not os.path.exists(cache_dir):
|
|
15
|
+
os.makedirs(cache_dir)
|
|
16
|
+
|
|
17
|
+
g.cache_file = os.path.join(cache_dir, g.CACHE_FILE_NAME)
|
|
18
|
+
|
|
19
|
+
if not os.path.exists(g.cache_file):
|
|
20
|
+
with open(g.cache_file, "w") as f:
|
|
21
|
+
print("Creating cache file at", g.cache_file)
|
|
22
|
+
f.write("{}")
|
|
23
|
+
|
|
24
|
+
g.appname = appname.replace(" ", "")
|
|
25
|
+
|
|
26
|
+
global _binder
|
|
27
|
+
if u.is_gnome_wayland():
|
|
28
|
+
global _comms
|
|
29
|
+
from . import linux_comms
|
|
30
|
+
_comms = linux_comms.ProcessCommunicator.get_global_communicator()
|
|
31
|
+
from .gnome_binder import GnomeBinder
|
|
32
|
+
_binder = GnomeBinder()
|
|
33
|
+
_comms.start_server(_binder.handle_shortcut)
|
|
34
|
+
else:
|
|
35
|
+
from .pynput_binder import PynputBinder
|
|
36
|
+
_binder = PynputBinder()
|
|
37
|
+
|
|
38
|
+
def deinit():
|
|
39
|
+
global _comms
|
|
40
|
+
if _comms is not None:
|
|
41
|
+
_comms.cleanup()
|
|
42
|
+
if _binder is not None:
|
|
43
|
+
_binder.cleanup()
|
|
44
|
+
|
|
45
|
+
def binding_handle(appname: str):
|
|
46
|
+
g.appname = appname.replace(" ", "")
|
|
47
|
+
if len(sys.argv) < 4:
|
|
48
|
+
return
|
|
49
|
+
if g.BINDING_ID_STR == sys.argv[1]:
|
|
50
|
+
pygsstr, input_appname, unique_id = sys.argv[2].split(g.BASH_COMMAND_SEPERATOR)
|
|
51
|
+
shortcut = sys.argv[3]
|
|
52
|
+
assert pygsstr == "pygs"
|
|
53
|
+
print("Handling binding for app:", sys.argv)
|
|
54
|
+
if input_appname != appname:
|
|
55
|
+
# This is not our binding, ignore
|
|
56
|
+
print("App name does not match, exiting.", appname, g.appname)
|
|
57
|
+
exit(0)
|
|
58
|
+
print("Setting unique ID to", unique_id)
|
|
59
|
+
u.set_unique_id(unique_id)
|
|
60
|
+
print("Setting unique ID to", unique_id, u.unique_id())
|
|
61
|
+
if u.is_gnome_wayland():
|
|
62
|
+
from . import linux_comms
|
|
63
|
+
comms = linux_comms.ProcessCommunicator.get_global_communicator()
|
|
64
|
+
if not comms.socket_exists():
|
|
65
|
+
# No socket to communicate with, exit
|
|
66
|
+
print("No socket to communicate with, exiting.")
|
|
67
|
+
exit(0)
|
|
68
|
+
print("Sending shortcut activation for", shortcut)
|
|
69
|
+
comms.send_json({"shortcut": f"{shortcut}"})
|
|
70
|
+
exit(0)
|
|
71
|
+
|
|
72
|
+
def get_binder() -> sb.KeyBinder:
|
|
73
|
+
global _binder
|
|
74
|
+
if _binder is None:
|
|
75
|
+
raise RuntimeError("Binder not initialized. Call init() first.")
|
|
76
|
+
return _binder
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import os
|
|
3
|
+
from typing import TypeVar, Generic
|
|
4
|
+
import typing as t
|
|
5
|
+
|
|
6
|
+
class CustomJSONEncoder(json.JSONEncoder):
|
|
7
|
+
def default(self, obj):
|
|
8
|
+
try:
|
|
9
|
+
return super().default(obj)
|
|
10
|
+
except TypeError:
|
|
11
|
+
if type(obj) in FiledDict._custom_serializers:
|
|
12
|
+
return FiledDict._custom_serializers[type(obj)](obj)
|
|
13
|
+
return str(obj)
|
|
14
|
+
|
|
15
|
+
class CustomJSONDecoder(json.JSONDecoder):
|
|
16
|
+
def __init__(self, *args, **kwargs):
|
|
17
|
+
json.JSONDecoder.__init__(self, object_hook=self._custom_object_hook, *args, **kwargs)
|
|
18
|
+
|
|
19
|
+
def _custom_object_hook(self, dct):
|
|
20
|
+
if FiledDict._custom_dict_deserializer is not None:
|
|
21
|
+
try:
|
|
22
|
+
return FiledDict._custom_dict_deserializer(dct)
|
|
23
|
+
except (KeyError, ValueError, TypeError) as e:
|
|
24
|
+
pass
|
|
25
|
+
return dct
|
|
26
|
+
|
|
27
|
+
K = TypeVar('K')
|
|
28
|
+
V = TypeVar('V')
|
|
29
|
+
class FiledDict(Generic[K, V]):
|
|
30
|
+
_custom_serializers = {}
|
|
31
|
+
_custom_dict_deserializer: t.Callable = None
|
|
32
|
+
|
|
33
|
+
@staticmethod
|
|
34
|
+
def register_serializer(type, serializer):
|
|
35
|
+
FiledDict._custom_serializers[type] = serializer
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
""" Tries its best to mimick a json file, and reflect changes in file upon saving.
|
|
39
|
+
Very useful for handling settings that need to be persistent
|
|
40
|
+
"""
|
|
41
|
+
|
|
42
|
+
def __init__(self, file: str, autosave: bool = True, default: t.Dict[K, V] = {}):
|
|
43
|
+
""" The file to mirror
|
|
44
|
+
serializer
|
|
45
|
+
Args:
|
|
46
|
+
file (string): path to the file
|
|
47
|
+
"""
|
|
48
|
+
self.file = file
|
|
49
|
+
self.autosave = autosave
|
|
50
|
+
self.default = default
|
|
51
|
+
if (not self.load()):
|
|
52
|
+
self._settings = default
|
|
53
|
+
self.save()
|
|
54
|
+
self.type = type(self._settings)
|
|
55
|
+
|
|
56
|
+
def __iter__(self):
|
|
57
|
+
return iter(self._settings)
|
|
58
|
+
|
|
59
|
+
def __len__(self):
|
|
60
|
+
return len(self._settings)
|
|
61
|
+
|
|
62
|
+
def __getitem__(self, name: K) -> V:
|
|
63
|
+
return self._settings.__getitem__(name)
|
|
64
|
+
|
|
65
|
+
def __setitem__(self, name: K, val: V):
|
|
66
|
+
self._settings.__setitem__(name, val)
|
|
67
|
+
if self.autosave:
|
|
68
|
+
self.save()
|
|
69
|
+
|
|
70
|
+
def __delitem__(self, key: K):
|
|
71
|
+
self._settings.__delitem__(key)
|
|
72
|
+
if self.autosave:
|
|
73
|
+
self.save()
|
|
74
|
+
|
|
75
|
+
def __getattr__(self, name):
|
|
76
|
+
return getattr(self._settings, name)
|
|
77
|
+
|
|
78
|
+
def save(self):
|
|
79
|
+
if (self.file is not None):
|
|
80
|
+
f = open(self.file,'w')
|
|
81
|
+
f.write(json.dumps(self._settings, indent=4, cls=CustomJSONEncoder))
|
|
82
|
+
|
|
83
|
+
def load(self):
|
|
84
|
+
if (self.file is None):
|
|
85
|
+
return False
|
|
86
|
+
if os.path.isfile(self.file):
|
|
87
|
+
f = open(self.file,'r')
|
|
88
|
+
try:
|
|
89
|
+
self._settings = json.load(f, cls=CustomJSONDecoder)
|
|
90
|
+
except json.JSONDecodeError as e:
|
|
91
|
+
print("Failed to load settings file, using default.", str(e))
|
|
92
|
+
self._settings = self.default
|
|
93
|
+
self.save()
|
|
94
|
+
finally:
|
|
95
|
+
f.close()
|
|
96
|
+
print(self._settings)
|
|
97
|
+
return True
|
|
98
|
+
return False
|
|
99
|
+
|
|
100
|
+
def keys(self) -> t.Iterable[K]:
|
|
101
|
+
return self._settings.keys()
|
|
102
|
+
|
|
103
|
+
def values(self) -> t.Iterable[V]:
|
|
104
|
+
return self._settings.values()
|
|
105
|
+
|
|
106
|
+
def items(self) -> t.Iterable[t.Tuple[K, V]]:
|
|
107
|
+
return self._settings.items()
|
|
108
|
+
|
|
109
|
+
def pop(self, key: K, if_fail: V = "") -> V:
|
|
110
|
+
return self._settings.pop(key, if_fail)
|
|
111
|
+
|
|
112
|
+
def remove(self, item: V) -> None:
|
|
113
|
+
return self._settings.remove(item)
|
|
114
|
+
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
# Used to store the apps current shortcuts
|
|
2
|
+
BINDING_ID_STR = "??++??"
|
|
3
|
+
CACHE_FILE_NAME = "py_global_shortcuts_cache.json"
|
|
4
|
+
GSETTINGS_SCHEMA = "org.gnome.settings-daemon.plugins.media-keys"
|
|
5
|
+
GSETTINGS_KEY = "custom-keybindings"
|
|
6
|
+
BASH_COMMAND_SEPERATOR = "___"
|
|
7
|
+
|
|
8
|
+
cache_file = ""
|
|
9
|
+
appname = None
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import re
|
|
2
|
+
from . import utilities as u
|
|
3
|
+
from . import shortcut_binder as sb
|
|
4
|
+
from . import globals as g
|
|
5
|
+
from typing import Union
|
|
6
|
+
import subprocess
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class GnomeBinder(sb.KeyBinder):
|
|
10
|
+
def _get_binding_path_prefix(self) -> str:
|
|
11
|
+
return f"/org/gnome/settings-daemon/plugins/media-keys/custom-keybindings/{u.unique_id()}"
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _deregister_key_binding(self, shortcut: str):
|
|
15
|
+
current_bindings = self._get_gsettings_bindings_list()
|
|
16
|
+
bindingpath = f"{self._get_binding_path_prefix()}/{shortcut}/"
|
|
17
|
+
if bindingpath not in current_bindings:
|
|
18
|
+
return
|
|
19
|
+
current_bindings.remove(bindingpath)
|
|
20
|
+
self._set_gsettings_bindings_list(current_bindings)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _set_gsettings_bindings_list(self, bindings: list[str]):
|
|
24
|
+
subprocess.run(
|
|
25
|
+
["gsettings", "set", g.GSETTINGS_SCHEMA, g.GSETTINGS_KEY, str(bindings)],
|
|
26
|
+
capture_output=True
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
def _remove_current_bindings_from_gsettings(self):
|
|
30
|
+
schema = "org.gnome.settings-daemon.plugins.media-keys"
|
|
31
|
+
key = "custom-keybindings"
|
|
32
|
+
|
|
33
|
+
current_bindings = self._get_gsettings_bindings_list()
|
|
34
|
+
updated_bindings = []
|
|
35
|
+
for b in current_bindings:
|
|
36
|
+
if not b.startswith(self._get_binding_path_prefix()):
|
|
37
|
+
updated_bindings.append(b)
|
|
38
|
+
self._set_gsettings_bindings_list(updated_bindings)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _get_gsettings_bindings_list(self) -> list[str]:
|
|
42
|
+
# Get the current list of custom keybindings
|
|
43
|
+
result = subprocess.run(
|
|
44
|
+
["gsettings", "get", g.GSETTINGS_SCHEMA, g.GSETTINGS_KEY],
|
|
45
|
+
capture_output=True,
|
|
46
|
+
text=True
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
current_bindings = result.stdout.strip()
|
|
50
|
+
current_bindings = re.sub(r'^@as\s+', '', current_bindings)
|
|
51
|
+
current_bindings = eval(current_bindings)
|
|
52
|
+
|
|
53
|
+
return current_bindings
|
|
54
|
+
|
|
55
|
+
def _get_current_gsettings_bindings(self):
|
|
56
|
+
current_bindings = self._get_gsettings_bindings_list()
|
|
57
|
+
|
|
58
|
+
app_bindings = []
|
|
59
|
+
for binding_path in current_bindings:
|
|
60
|
+
if binding_path.startswith(self._get_binding_path_prefix()):
|
|
61
|
+
# Get the shortcut details for this binding
|
|
62
|
+
binding_schema = f"{g.GSETTINGS_SCHEMA}.custom-keybinding:{binding_path}"
|
|
63
|
+
name = subprocess.run(
|
|
64
|
+
["gsettings", "get", binding_schema, "name"],
|
|
65
|
+
capture_output=True,
|
|
66
|
+
text=True
|
|
67
|
+
).stdout.strip().strip("'")
|
|
68
|
+
command = subprocess.run(
|
|
69
|
+
["gsettings", "get", binding_schema, "command"],
|
|
70
|
+
capture_output=True,
|
|
71
|
+
text=True
|
|
72
|
+
).stdout.strip().strip("'")
|
|
73
|
+
shortcut = subprocess.run(
|
|
74
|
+
["gsettings", "get", binding_schema, "binding"],
|
|
75
|
+
capture_output=True,
|
|
76
|
+
text=True
|
|
77
|
+
).stdout.strip().strip("'")
|
|
78
|
+
|
|
79
|
+
app_bindings.append(shortcut)
|
|
80
|
+
|
|
81
|
+
return app_bindings
|
|
82
|
+
|
|
83
|
+
def cleanup(self):
|
|
84
|
+
self._remove_current_bindings_from_gsettings()
|
|
85
|
+
|
|
86
|
+
def _register_key_binding(self, shortcut):
|
|
87
|
+
# binding_command = f"terminator -x bash -c '{u.get_exec_bash(shortcut)}; exec bash'"
|
|
88
|
+
binding_command = f"{u.get_exec_bash(shortcut)}"
|
|
89
|
+
self.gnome_register_key_binding(shortcut, binding_command)
|
|
90
|
+
|
|
91
|
+
def _sanitize_binding_str_for_gnome(self, binding_str: str) -> str:
|
|
92
|
+
if "+" in binding_str:
|
|
93
|
+
binding_str = binding_str.replace("+", "")
|
|
94
|
+
return binding_str.lower()
|
|
95
|
+
|
|
96
|
+
def gnome_register_key_binding(self, shortcut, command):
|
|
97
|
+
# Pynput has a slightly different style
|
|
98
|
+
shortcut = self._sanitize_binding_str_for_gnome(shortcut)
|
|
99
|
+
|
|
100
|
+
# Get the current list of custom keybindings
|
|
101
|
+
result = subprocess.run(
|
|
102
|
+
["gsettings", "get", g.GSETTINGS_SCHEMA, g.GSETTINGS_KEY],
|
|
103
|
+
capture_output=True,
|
|
104
|
+
text=True
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
current_bindings = result.stdout.strip()
|
|
108
|
+
current_bindings = re.sub(r'^@as\s+', '', current_bindings)
|
|
109
|
+
current_bindings = eval(current_bindings)
|
|
110
|
+
|
|
111
|
+
# Define the new binding path
|
|
112
|
+
new_binding_path = f"/org/gnome/settings-daemon/plugins/media-keys/custom-keybindings/{u.unique_id()}/{shortcut.replace(' ', '_')}/"
|
|
113
|
+
|
|
114
|
+
# Add the new binding if it doesn't exist
|
|
115
|
+
if new_binding_path not in current_bindings:
|
|
116
|
+
current_bindings.append(new_binding_path)
|
|
117
|
+
subprocess.run(
|
|
118
|
+
["gsettings", "set", g.GSETTINGS_SCHEMA, g.GSETTINGS_KEY, str(current_bindings)],
|
|
119
|
+
capture_output=True
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
# Set the name, command, and binding for the new shortcut
|
|
123
|
+
subprocess.run(
|
|
124
|
+
["gsettings", "set", f"{g.GSETTINGS_SCHEMA}.custom-keybinding:{new_binding_path}", "name", f"Custom Shortcut {shortcut}"],
|
|
125
|
+
capture_output=True
|
|
126
|
+
)
|
|
127
|
+
subprocess.run(
|
|
128
|
+
["gsettings", "set", f"{g.GSETTINGS_SCHEMA}.custom-keybinding:{new_binding_path}", "command", command],
|
|
129
|
+
capture_output=True
|
|
130
|
+
)
|
|
131
|
+
subprocess.run(
|
|
132
|
+
["gsettings", "set", f"{g.GSETTINGS_SCHEMA}.custom-keybinding:{new_binding_path}", "binding", shortcut],
|
|
133
|
+
capture_output=True
|
|
134
|
+
)
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import socket
|
|
3
|
+
import threading
|
|
4
|
+
from . import utilities as u
|
|
5
|
+
import os
|
|
6
|
+
|
|
7
|
+
class ProcessCommunicator:
|
|
8
|
+
"""Handle JSON communication between Python processes using Unix domain sockets."""
|
|
9
|
+
global_instance = None
|
|
10
|
+
|
|
11
|
+
def __init__(self, socket_path=None):
|
|
12
|
+
"""Initialize communicator with optional socket path.
|
|
13
|
+
|
|
14
|
+
Args:
|
|
15
|
+
socket_path: Path to Unix domain socket. Auto-generated if None.
|
|
16
|
+
"""
|
|
17
|
+
self.socket_path = socket_path or f"/tmp/pygs_{u.unique_id()}.sock"
|
|
18
|
+
self.server_socket = None
|
|
19
|
+
self.client_socket = None
|
|
20
|
+
|
|
21
|
+
def socket_exists(self):
|
|
22
|
+
"""Check if the socket file exists."""
|
|
23
|
+
return os.path.exists(self.socket_path)
|
|
24
|
+
|
|
25
|
+
def start_server(self, callback):
|
|
26
|
+
"""Start server to receive JSON messages.
|
|
27
|
+
|
|
28
|
+
Args:
|
|
29
|
+
callback: Function called with deserialized JSON data
|
|
30
|
+
"""
|
|
31
|
+
# Remove existing socket file
|
|
32
|
+
if os.path.exists(self.socket_path):
|
|
33
|
+
os.remove(self.socket_path)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
self.server_socket = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
|
37
|
+
self.server_socket.bind(self.socket_path)
|
|
38
|
+
self.server_socket.listen(1)
|
|
39
|
+
|
|
40
|
+
def accept_connections():
|
|
41
|
+
while True:
|
|
42
|
+
try:
|
|
43
|
+
conn, _ = self.server_socket.accept()
|
|
44
|
+
data = b""
|
|
45
|
+
while True:
|
|
46
|
+
chunk = conn.recv(4096)
|
|
47
|
+
if not chunk:
|
|
48
|
+
break
|
|
49
|
+
data += chunk
|
|
50
|
+
if data:
|
|
51
|
+
callback(json.loads(data.decode())["shortcut"])
|
|
52
|
+
conn.close()
|
|
53
|
+
except:
|
|
54
|
+
break
|
|
55
|
+
|
|
56
|
+
thread = threading.Thread(target=accept_connections, daemon=True)
|
|
57
|
+
thread.start()
|
|
58
|
+
|
|
59
|
+
def send_json(self, data):
|
|
60
|
+
"""Send JSON data to server socket.
|
|
61
|
+
|
|
62
|
+
Args:
|
|
63
|
+
data: Dictionary or JSON-serializable object to send
|
|
64
|
+
"""
|
|
65
|
+
self.client_socket = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
|
66
|
+
self.client_socket.connect(self.socket_path)
|
|
67
|
+
json_str = json.dumps(data)
|
|
68
|
+
self.client_socket.sendall(json_str.encode())
|
|
69
|
+
self.client_socket.close()
|
|
70
|
+
|
|
71
|
+
def close(self):
|
|
72
|
+
"""Close server and client sockets."""
|
|
73
|
+
if self.server_socket:
|
|
74
|
+
self.server_socket.close()
|
|
75
|
+
if self.client_socket:
|
|
76
|
+
self.client_socket.close()
|
|
77
|
+
|
|
78
|
+
def cleanup(self):
|
|
79
|
+
"""Close sockets and remove socket file."""
|
|
80
|
+
self.close()
|
|
81
|
+
if os.path.exists(self.socket_path):
|
|
82
|
+
os.remove(self.socket_path)
|
|
83
|
+
|
|
84
|
+
@staticmethod
|
|
85
|
+
def get_global_communicator():
|
|
86
|
+
if ProcessCommunicator.global_instance is None:
|
|
87
|
+
ProcessCommunicator.global_instance = ProcessCommunicator()
|
|
88
|
+
return ProcessCommunicator.global_instance
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import re
|
|
2
|
+
from . import utilities as u
|
|
3
|
+
from . import shortcut_binder as sb
|
|
4
|
+
from . import globals as g
|
|
5
|
+
from typing import Union
|
|
6
|
+
import subprocess
|
|
7
|
+
import pynput
|
|
8
|
+
|
|
9
|
+
class PynputBinder(sb.KeyBinder):
|
|
10
|
+
def __init__(self):
|
|
11
|
+
self._hotkeys: dict[str, pynput.keyboard.HotKey] = {}
|
|
12
|
+
self._key_listener = pynput.keyboard.Listener(on_press=self._on_press, on_release=self._on_release)
|
|
13
|
+
self._key_listener.start()
|
|
14
|
+
super().__init__()
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _on_press(self, key):
|
|
18
|
+
can = self._key_listener.canonical(key)
|
|
19
|
+
for hotkey in self._hotkeys.values():
|
|
20
|
+
hotkey.press(can)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _on_release(self, key):
|
|
24
|
+
can = self._key_listener.canonical(key)
|
|
25
|
+
for hotkey in self._hotkeys.values():
|
|
26
|
+
hotkey.release(can)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _deregister_key_binding(self, shortcut: str):
|
|
30
|
+
if shortcut in self._hotkeys:
|
|
31
|
+
del self._hotkeys[shortcut]
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def cleanup(self):
|
|
35
|
+
self._key_listener.stop()
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _create_hotkey_fun(self, shortcut: str):
|
|
39
|
+
def fun():
|
|
40
|
+
self.handle_shortcut(shortcut)
|
|
41
|
+
return fun
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _register_key_binding(self, shortcut):
|
|
45
|
+
if shortcut in self._hotkeys:
|
|
46
|
+
return
|
|
47
|
+
keys = pynput.keyboard.HotKey.parse(shortcut)
|
|
48
|
+
for i in range(len(keys)):
|
|
49
|
+
keys[i] = self._key_listener.canonical(keys[i])
|
|
50
|
+
|
|
51
|
+
self._hotkeys[shortcut] = pynput.keyboard.HotKey(keys, self._create_hotkey_fun(shortcut))
|
|
52
|
+
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
from typing import Callable, Union
|
|
2
|
+
from . import globals as g
|
|
3
|
+
from . filed_dict import FiledDict, CustomJSONEncoder
|
|
4
|
+
import os
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class Command:
|
|
8
|
+
def __init__(self, cmd_id: str, function: Callable, name: str = ""):
|
|
9
|
+
self.name = name
|
|
10
|
+
self.function: Callable = function
|
|
11
|
+
self.cmd_id = cmd_id
|
|
12
|
+
if name == "":
|
|
13
|
+
self.name = self.cmd_id
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def __repr__(self):
|
|
17
|
+
return f"Command({self.name}, {self.function})"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def __str__(self):
|
|
21
|
+
return self.__repr__()
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def execute(self):
|
|
25
|
+
self.function()
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class KeyBinding:
|
|
29
|
+
def __init__(self, shortcut: str):
|
|
30
|
+
self.shortcut = shortcut
|
|
31
|
+
self.commands: list[str] = []
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def __repr__(self):
|
|
35
|
+
return f"KeyBinding({self.shortcut}, {self.commands})"
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def __str__(self):
|
|
39
|
+
return self.__repr__()
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def get_commands(self) -> list[str]:
|
|
43
|
+
return self.commands
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def add_command(self, command: Union[Command, str]):
|
|
47
|
+
if isinstance(command, Command):
|
|
48
|
+
command = command.cmd_id
|
|
49
|
+
if self.has_command(command):
|
|
50
|
+
return
|
|
51
|
+
self.commands.append(command)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def remove_command(self, command: Union[Command, str]):
|
|
55
|
+
if isinstance(command, Command):
|
|
56
|
+
command = command.cmd_id
|
|
57
|
+
if command in self.commands:
|
|
58
|
+
self.commands.remove(command)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def has_command(self, command: Union[Command, str]) -> bool:
|
|
62
|
+
if isinstance(command, Command):
|
|
63
|
+
command = command.cmd_id
|
|
64
|
+
return command in self.commands
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class KeyBinder:
|
|
68
|
+
def __init__(self):
|
|
69
|
+
self.shortcuts: FiledDict[str, KeyBinding] = FiledDict(g.cache_file, autosave=True, default={})
|
|
70
|
+
self.commands: dict[str, Command] = {}
|
|
71
|
+
for shortcut in self.shortcuts.keys():
|
|
72
|
+
self._register_key_binding(shortcut)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def register_command(self, command: Command):
|
|
76
|
+
self.commands[command.cmd_id] = command
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def get_key_bindings(self) -> list[KeyBinding]:
|
|
80
|
+
return list(self.shortcuts.values())
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def get_key_binding(self, shortcut: str) -> KeyBinding:
|
|
84
|
+
shortcut = self._sanitize_binding_str(shortcut)
|
|
85
|
+
if shortcut in self.shortcuts:
|
|
86
|
+
return self.shortcuts[shortcut]
|
|
87
|
+
return None
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def get_commands(self) -> list[Command]:
|
|
91
|
+
return list(self.commands.values())
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def cleanup(self):
|
|
95
|
+
pass
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def execute_command(self, command_id: Union[Command, str]):
|
|
99
|
+
if isinstance(command_id, Command):
|
|
100
|
+
command_id = command_id.id
|
|
101
|
+
if command_id in self.commands:
|
|
102
|
+
command = self.commands[command_id]
|
|
103
|
+
command.execute()
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def delete_key_binding(self, shortcut: str):
|
|
107
|
+
shortcut = self._sanitize_binding_str(shortcut)
|
|
108
|
+
if shortcut in self.shortcuts:
|
|
109
|
+
del self.shortcuts[shortcut]
|
|
110
|
+
self._deregister_key_binding(shortcut)
|
|
111
|
+
self.shortcuts.save()
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def create_key_binding(self, shortcut: str):
|
|
115
|
+
shortcut = self._sanitize_binding_str(shortcut)
|
|
116
|
+
if shortcut not in self.shortcuts:
|
|
117
|
+
self.shortcuts[shortcut] = KeyBinding(shortcut)
|
|
118
|
+
self._register_key_binding(shortcut)
|
|
119
|
+
self.shortcuts.save()
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def unlink_command(self, shortcut: str, command: Union[Command, str]):
|
|
123
|
+
shortcut = self._sanitize_binding_str(shortcut)
|
|
124
|
+
if isinstance(command, Command):
|
|
125
|
+
command = command.cmd_id
|
|
126
|
+
if shortcut in self.shortcuts:
|
|
127
|
+
self.shortcuts[shortcut].remove_command(command)
|
|
128
|
+
self.shortcuts.save()
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def link_command(self, shortcut: str, command: Union[Command, str]):
|
|
132
|
+
shortcut = self._sanitize_binding_str(shortcut)
|
|
133
|
+
if shortcut not in self.shortcuts:
|
|
134
|
+
self.shortcuts[shortcut] = KeyBinding(shortcut)
|
|
135
|
+
self.shortcuts[shortcut].add_command(command)
|
|
136
|
+
self._register_key_binding(shortcut)
|
|
137
|
+
self.shortcuts.save()
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def handle_shortcut(self, shortcut: str):
|
|
141
|
+
shortcut = self._sanitize_binding_str(shortcut)
|
|
142
|
+
if shortcut in self.shortcuts:
|
|
143
|
+
for command_id in self.shortcuts[shortcut].get_commands():
|
|
144
|
+
if command_id in self.commands:
|
|
145
|
+
command = self.commands[command_id]
|
|
146
|
+
command.execute()
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def _register_key_binding(self, shortcut: str):
|
|
150
|
+
raise NotImplementedError("This method should be implemented by subclasses.")
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def _deregister_key_binding(self, shortcut: str):
|
|
154
|
+
raise NotImplementedError("This method should be implemented by subclasses.")
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def _sanitize_binding_str(self, binding_str: str) -> str:
|
|
158
|
+
return binding_str.lower()
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def _binding_serializer(bnd: 'KeyBinding'):
|
|
162
|
+
return {
|
|
163
|
+
"shortcut": bnd.shortcut,
|
|
164
|
+
"commands": bnd.get_commands(),
|
|
165
|
+
"_cls_": "KeyBinding"
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def _binding_deserializer(dct):
|
|
170
|
+
if "_cls_" in dct and dct["_cls_"] == "KeyBinding":
|
|
171
|
+
ret = KeyBinding(dct["shortcut"])
|
|
172
|
+
for cmd_id in dct["commands"]:
|
|
173
|
+
ret.add_command(cmd_id)
|
|
174
|
+
return ret
|
|
175
|
+
return dct
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
FiledDict.register_serializer(KeyBinding, _binding_serializer)
|
|
179
|
+
FiledDict._custom_dict_deserializer = _binding_deserializer
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import uuid
|
|
2
|
+
import platform
|
|
3
|
+
import os
|
|
4
|
+
import sys
|
|
5
|
+
|
|
6
|
+
from . import globals as g
|
|
7
|
+
|
|
8
|
+
# We need a unique ID per application instance to avoid conflicts
|
|
9
|
+
_unique_id_str = str(uuid.uuid4())
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def set_unique_id(id_str: str):
|
|
13
|
+
global _unique_id_str
|
|
14
|
+
_unique_id_str = id_str
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def app_id():
|
|
18
|
+
return f"pygs{g.BASH_COMMAND_SEPERATOR}{g.appname}"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def unique_id():
|
|
22
|
+
return f"{app_id()}{g.BASH_COMMAND_SEPERATOR}{_unique_id_str}"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def is_gnome_wayland():
|
|
26
|
+
if platform.system() != "Linux":
|
|
27
|
+
return False
|
|
28
|
+
return os.environ.get("XDG_SESSION_TYPE") == "wayland" and "ubuntu" in os.environ.get("DESKTOP_SESSION", "")
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def get_exec_bash(shortcut: str = ""):
|
|
32
|
+
main_python_file = os.path.abspath(sys.argv[0])
|
|
33
|
+
|
|
34
|
+
shortcut = shortcut.replace("\\", "\\\\").replace('"', '\\"').replace("$", "\\$").replace("`", "\\`").replace("<", "\\<").replace(">", "\\>")
|
|
35
|
+
|
|
36
|
+
return f"{sys.executable} {main_python_file} {g.BINDING_ID_STR} {unique_id()} {shortcut}"
|
|
37
|
+
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: py-global-shortcuts
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Python library for creating global keyboard shortcuts.
|
|
5
|
+
Requires-Python: >=3.10
|
|
6
|
+
Description-Content-Type: text/markdown
|
|
7
|
+
Requires-Dist: pynput>=1.8.1
|
|
8
|
+
|
|
9
|
+
# py-global-shortcuts
|
|
10
|
+
Global shortcuts for python apps using GNOME and Wayland
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
README.md
|
|
2
|
+
pyproject.toml
|
|
3
|
+
py_global_shortcuts/__init__.py
|
|
4
|
+
py_global_shortcuts/filed_dict.py
|
|
5
|
+
py_global_shortcuts/globals.py
|
|
6
|
+
py_global_shortcuts/gnome_binder.py
|
|
7
|
+
py_global_shortcuts/linux_comms.py
|
|
8
|
+
py_global_shortcuts/pynput_binder.py
|
|
9
|
+
py_global_shortcuts/shortcut_binder.py
|
|
10
|
+
py_global_shortcuts/utilities.py
|
|
11
|
+
py_global_shortcuts.egg-info/PKG-INFO
|
|
12
|
+
py_global_shortcuts.egg-info/SOURCES.txt
|
|
13
|
+
py_global_shortcuts.egg-info/dependency_links.txt
|
|
14
|
+
py_global_shortcuts.egg-info/requires.txt
|
|
15
|
+
py_global_shortcuts.egg-info/top_level.txt
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
pynput>=1.8.1
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
py_global_shortcuts
|