streamdeck-gui-ng 4.1.3__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.
Files changed (62) hide show
  1. streamdeck_gui_ng-4.1.3.dist-info/METADATA +141 -0
  2. streamdeck_gui_ng-4.1.3.dist-info/RECORD +62 -0
  3. streamdeck_gui_ng-4.1.3.dist-info/WHEEL +4 -0
  4. streamdeck_gui_ng-4.1.3.dist-info/entry_points.txt +4 -0
  5. streamdeck_gui_ng-4.1.3.dist-info/licenses/LICENSE +21 -0
  6. streamdeck_ui/__init__.py +6 -0
  7. streamdeck_ui/api.py +712 -0
  8. streamdeck_ui/button.ui +1214 -0
  9. streamdeck_ui/cli/__init__.py +0 -0
  10. streamdeck_ui/cli/commands.py +191 -0
  11. streamdeck_ui/cli/server.py +292 -0
  12. streamdeck_ui/config.py +244 -0
  13. streamdeck_ui/dimmer.py +93 -0
  14. streamdeck_ui/display/__init__.py +0 -0
  15. streamdeck_ui/display/background_color_filter.py +41 -0
  16. streamdeck_ui/display/display_grid.py +265 -0
  17. streamdeck_ui/display/empty_filter.py +43 -0
  18. streamdeck_ui/display/filter.py +65 -0
  19. streamdeck_ui/display/image_filter.py +144 -0
  20. streamdeck_ui/display/keypress_filter.py +63 -0
  21. streamdeck_ui/display/pipeline.py +74 -0
  22. streamdeck_ui/display/pulse_filter.py +54 -0
  23. streamdeck_ui/display/text_filter.py +142 -0
  24. streamdeck_ui/fonts/roboto/LICENSE.txt +202 -0
  25. streamdeck_ui/fonts/roboto/Roboto-Black.ttf +0 -0
  26. streamdeck_ui/fonts/roboto/Roboto-BlackItalic.ttf +0 -0
  27. streamdeck_ui/fonts/roboto/Roboto-Bold.ttf +0 -0
  28. streamdeck_ui/fonts/roboto/Roboto-BoldItalic.ttf +0 -0
  29. streamdeck_ui/fonts/roboto/Roboto-Italic.ttf +0 -0
  30. streamdeck_ui/fonts/roboto/Roboto-Light.ttf +0 -0
  31. streamdeck_ui/fonts/roboto/Roboto-LightItalic.ttf +0 -0
  32. streamdeck_ui/fonts/roboto/Roboto-Medium.ttf +0 -0
  33. streamdeck_ui/fonts/roboto/Roboto-MediumItalic.ttf +0 -0
  34. streamdeck_ui/fonts/roboto/Roboto-Regular.ttf +0 -0
  35. streamdeck_ui/fonts/roboto/Roboto-Thin.ttf +0 -0
  36. streamdeck_ui/fonts/roboto/Roboto-ThinItalic.ttf +0 -0
  37. streamdeck_ui/gui.py +1423 -0
  38. streamdeck_ui/icons/add_page.png +0 -0
  39. streamdeck_ui/icons/cross.png +0 -0
  40. streamdeck_ui/icons/gear.png +0 -0
  41. streamdeck_ui/icons/horizontal-align.png +0 -0
  42. streamdeck_ui/icons/remove_page.png +0 -0
  43. streamdeck_ui/icons/vertical-align.png +0 -0
  44. streamdeck_ui/icons/warning_icon_button.png +0 -0
  45. streamdeck_ui/logger.py +11 -0
  46. streamdeck_ui/logo.png +0 -0
  47. streamdeck_ui/main.ui +407 -0
  48. streamdeck_ui/mock_streamdeck.py +204 -0
  49. streamdeck_ui/model.py +78 -0
  50. streamdeck_ui/modules/__init__.py +0 -0
  51. streamdeck_ui/modules/fonts.py +150 -0
  52. streamdeck_ui/modules/keyboard.py +447 -0
  53. streamdeck_ui/modules/utils/__init__.py +0 -0
  54. streamdeck_ui/modules/utils/timers.py +35 -0
  55. streamdeck_ui/resources.qrc +10 -0
  56. streamdeck_ui/resources_rc.py +324 -0
  57. streamdeck_ui/semaphore.py +38 -0
  58. streamdeck_ui/settings.ui +155 -0
  59. streamdeck_ui/stream_deck_monitor.py +157 -0
  60. streamdeck_ui/ui_button.py +421 -0
  61. streamdeck_ui/ui_main.py +267 -0
  62. streamdeck_ui/ui_settings.py +119 -0
streamdeck_ui/model.py ADDED
@@ -0,0 +1,78 @@
1
+ from dataclasses import dataclass, field
2
+ from typing import Dict
3
+
4
+
5
+ @dataclass
6
+ class ButtonState:
7
+ text: str = ""
8
+ """Text to display on the button"""
9
+ icon: str = ""
10
+ """Icon to display on the button"""
11
+ keys: str = ""
12
+ """Combination of keys, actionable by the button"""
13
+ write: str = ""
14
+ """Text to write, actionable by the button"""
15
+ command: str = ""
16
+ """Command to execute, actionable by the button"""
17
+ switch_page: int = 0
18
+ """Page to switch, actionable by the button"""
19
+ switch_state: int = 0
20
+ """Button state to switch, actionable by the button"""
21
+ brightness_change: int = 0
22
+ """Brightness percent change (-/+), actionable by the button"""
23
+ text_vertical_align: str = ""
24
+ """Vertical alignment of the text on the button"""
25
+ text_horizontal_align: str = ""
26
+ """Horizontal alignment of the text on the button"""
27
+ font: str = ""
28
+ """Font of the text on the button"""
29
+ font_color: str = ""
30
+ """Font color of the text on the button"""
31
+ font_size: int = 0
32
+ """Font size of the text on the button"""
33
+ background_color: str = ""
34
+ """Background color of the button"""
35
+ force_refresh: bool = False
36
+ """Force icon refresh after command execution"""
37
+
38
+
39
+ @dataclass
40
+ class ButtonMultiState:
41
+ state: int = 0
42
+ """Current displayed state of the button"""
43
+ states: Dict[int, ButtonState] = field(default_factory=dict)
44
+ """States of the button"""
45
+
46
+
47
+ @dataclass
48
+ class DeckState:
49
+ buttons: Dict[int, Dict[int, ButtonMultiState]] = field(default_factory=dict)
50
+ """State of Pages/buttons of the StreamDeck"""
51
+ display_timeout: int = 1800
52
+ """Timeout in seconds before dimming the StreamDeck display"""
53
+ brightness: int = 100
54
+ """"Brightness level of the StreamDeck"""
55
+ brightness_dimmed: int = 0
56
+ """Brightness level of the StreamDeck when dimmed"""
57
+ rotation: int = 0
58
+ """Rotation of the StreamDeck display"""
59
+ page: int = 0
60
+ """Current displayed page in the StreamDeck"""
61
+
62
+
63
+ @dataclass
64
+ class DeckStateV1:
65
+ """Old DeckState class, used for backward compatibility"""
66
+
67
+ buttons: Dict[int, Dict[int, ButtonState]] = field(default_factory=dict)
68
+ """State of Pages/buttons of the StreamDeck"""
69
+ display_timeout: int = 1800
70
+ """Timeout in seconds before dimming the StreamDeck display"""
71
+ brightness: int = 100
72
+ """"Brightness level of the StreamDeck"""
73
+ brightness_dimmed: int = 0
74
+ """Brightness level of the StreamDeck when dimmed"""
75
+ rotation: int = 0
76
+ """Rotation of the StreamDeck display"""
77
+ page: int = 0
78
+ """Current displayed page in the StreamDeck"""
File without changes
@@ -0,0 +1,150 @@
1
+ """Adds support for handling system fonts in Linux"""
2
+
3
+ import os
4
+ import re
5
+ import subprocess # nosec B404
6
+ from typing import Tuple
7
+
8
+ from PIL import ImageFont
9
+
10
+ from streamdeck_ui.config import DEFAULT_FONT, DEFAULT_FONT_SIZE, FONTS_FALLBACK_PATH
11
+
12
+ FONT_LANGUAGE = "en" # Change this to your desired font language code
13
+ SHOW_ALL_LANGUAGES = False # Set to True to show all languages
14
+
15
+
16
+ def get_fonts():
17
+ """Populates a font dictionary in the form: font_dictionary[font_family][font_style] = font_file"""
18
+ system_fonts_dict = get_system_fonts()
19
+ fallback_fonts_dict = get_fallback_fonts()
20
+
21
+ # Overwrite system fonts dict with the fallback fonts
22
+ # this ensures the default font gets pulled in with the appropriate file name
23
+ for fallback_font_family in fallback_fonts_dict:
24
+ if fallback_font_family not in system_fonts_dict:
25
+ system_fonts_dict[fallback_font_family] = {}
26
+ for fallback_font_style in fallback_fonts_dict[fallback_font_family].keys():
27
+ fallback_font_file = fallback_fonts_dict[fallback_font_family][fallback_font_style]
28
+ system_fonts_dict[fallback_font_family][fallback_font_style] = fallback_font_file
29
+
30
+ return reorder_font_styles(system_fonts_dict)
31
+
32
+
33
+ def get_system_fonts():
34
+ fonts_dict = {}
35
+ try:
36
+ fclist_locations = [
37
+ "/usr/bin/fc-list",
38
+ "/usr/sbin/fc-list",
39
+ "/bin/fc-list",
40
+ "/usr/local/sbin/fc-list",
41
+ "/usr/local/bin/fc-list",
42
+ ]
43
+ fclist_path = None
44
+ for file_path in fclist_locations:
45
+ if os.path.exists(file_path):
46
+ fclist_path = file_path
47
+ break
48
+ if fclist_path is None:
49
+ raise FileNotFoundError
50
+ except FileNotFoundError:
51
+ print("The 'fc-list' command is not available on your system. Using fallback fonts.")
52
+ else:
53
+ # Construct the fc-list command with language and columns for family, style, and file information
54
+ # including pixel size and then restricting len(font_data)==3 allows us to throw away some fonts that won't work with pillow
55
+ arg_language = ":"
56
+ if not SHOW_ALL_LANGUAGES:
57
+ if is_valid_language_code(FONT_LANGUAGE):
58
+ arg_language = ":lang=" + FONT_LANGUAGE
59
+ fclist_command = [fclist_path, arg_language, "file", "family", "style", "pixelsize"]
60
+ result = subprocess.run(fclist_command, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True) # nosec B603
61
+ if result.returncode != 0:
62
+ print("Error executing fc-list command: ", result.stderr)
63
+ return fonts_dict
64
+ # Split the output into lines
65
+ lines = result.stdout.split("\n")
66
+ # Extract the font family, style, and file information from each line
67
+ for line in lines:
68
+ if line.strip():
69
+ font_data = line.split(":")
70
+ # Every font has a file/family/style, since we also request pixel size in fc-list restricting len(font_data)==3
71
+ # means we will throw away any fonts that specify a pixel size
72
+ if len(font_data) == 3:
73
+ font_file = font_data[0].strip()
74
+ try:
75
+ ImageFont.truetype(font_file, DEFAULT_FONT_SIZE)
76
+ except OSError:
77
+ print("Pillow cannot render font... skipping: " + font_file)
78
+ else:
79
+ # Occasionally fc-list will return multiple font families/styles per font file (comma separated)
80
+ # Reversing font family and then taking the first item aligns best with my expectations given the font file name
81
+ font_family = font_data[1].strip().split(",")[::-1][0]
82
+ # Font style aligns best with my expectations if it is not reversed
83
+ font_style = font_data[2].strip().replace("style=", "").split(",")[0]
84
+ if font_family not in fonts_dict:
85
+ fonts_dict[font_family] = {font_style: font_file}
86
+ elif font_family in fonts_dict and font_style not in fonts_dict[font_family]:
87
+ fonts_dict[font_family][font_style] = font_file
88
+
89
+ fonts_dict = dict(sorted(fonts_dict.items()))
90
+ return fonts_dict
91
+
92
+
93
+ def get_fallback_fonts():
94
+ """Populate a font dictionary with the fallback fonts if their file names are in the style: FontFamily-FontStyle.ttf"""
95
+ fonts_dict = {}
96
+ # Define a regular expression pattern to split the font style by camel case
97
+ pattern = re.compile(r"(?<=[a-z])(?=[A-Z])")
98
+ font_files = os.listdir(FONTS_FALLBACK_PATH)
99
+
100
+ for font_file in font_files:
101
+ if font_file.endswith((".ttf", ".otf")):
102
+ parts = font_file.split("-")
103
+ # Extract font name and style and add dictionary entry
104
+ if len(parts) == 2:
105
+ font_family, extension = parts
106
+ font_style = extension.split(".")[0]
107
+ # Split the font style by camel case and add spaces
108
+ font_style_parts = pattern.split(font_style)
109
+ font_style = " ".join(font_style_parts)
110
+
111
+ if font_family not in fonts_dict:
112
+ fonts_dict[font_family] = {}
113
+ fonts_dict[font_family][font_style] = os.path.join(FONTS_FALLBACK_PATH, font_file)
114
+ return fonts_dict
115
+
116
+
117
+ def reorder_font_styles(fonts_dict):
118
+ """Reorders the font styles in the desired order, with those not specified remaining at the end in alphabetical order"""
119
+ desired_order = ["Regular", "Bold", "Italic", "Bold Italic"]
120
+ for font_family, font_styles in fonts_dict.items():
121
+ reordered = {
122
+ font_style: fonts_dict[font_family][font_style] for font_style in desired_order if font_style in font_styles
123
+ }
124
+ for font_style, font_file in sorted(font_styles.items()):
125
+ if font_style not in desired_order:
126
+ reordered[font_style] = font_file
127
+ fonts_dict[font_family] = reordered
128
+ return fonts_dict
129
+
130
+
131
+ def is_valid_language_code(code):
132
+ # Use a regular expression to check if the code is RFC-3066 compliant
133
+ return re.match(r"^[a-zA-Z]{2}(-[a-zA-Z0-9]+)*$", code) is not None
134
+
135
+
136
+ def find_font_info(target_font_file: str) -> Tuple[str, str]:
137
+ """Returns the font family and font style for a given font file path"""
138
+ # The font file path is the font attribute that is stored in the .streamdeck_ui.json
139
+ # we need the family/style for selecting the appropriate items in the combo boxes
140
+ if target_font_file == "":
141
+ target_font_file = DEFAULT_FONT
142
+ for font_family, font_styles in FONTS_DICT.items():
143
+ for font_style, font_file in font_styles.items():
144
+ if font_file.endswith(target_font_file):
145
+ return font_family, font_style
146
+ return find_font_info(DEFAULT_FONT)
147
+
148
+
149
+ FONTS_DICT = get_fonts()
150
+ DEFAULT_FONT_FAMILY, DEFAULT_FONT_STYLE = find_font_info(DEFAULT_FONT)
@@ -0,0 +1,447 @@
1
+ import time
2
+ from typing import Dict, List, Union
3
+
4
+ from evdev import InputDevice, UInput
5
+ from evdev import ecodes as e
6
+ from evdev import list_devices
7
+ from PySide6.QtCore import QStringListModel, QThread
8
+ from PySide6.QtWidgets import QCompleter
9
+
10
+ _DEFAULT_KEY_PRESS_DELAY = 0.05
11
+ _DEFAULT_KEY_SECTION_DELAY = 0.5
12
+
13
+ # As far as I know all the key syms in linux are integers below 1000
14
+ # use 2000 or above to signify a delay, and add the delay in deciseconds to this keysym value
15
+ # For example, if you would like a delay of 5 seconds --> 50 deciseconds, then the keysym would be 2050
16
+ _DELAY_KEYSYM = 2000
17
+ # Default delay to add when user uses delay keyword in deciseconds (1/10th of a second)
18
+ _DEFAULT_ADDITIONAL_DELAY = 5
19
+
20
+ # fmt: off
21
+ _SPECIAL_KEYS: Dict[str, str] = {
22
+ "plus": "+",
23
+ "comma": ",",
24
+ "delay": "delay",
25
+ }
26
+ _OLD_NUMPAD_KEYS: Dict[str, int] = {
27
+ "numpad_0": e.KEY_KP0,
28
+ "numpad_1": e.KEY_KP1,
29
+ "numpad_2": e.KEY_KP2,
30
+ "numpad_3": e.KEY_KP3,
31
+ "numpad_4": e.KEY_KP4,
32
+ "numpad_5": e.KEY_KP5,
33
+ "numpad_6": e.KEY_KP6,
34
+ "numpad_7": e.KEY_KP7,
35
+ "numpad_8": e.KEY_KP8,
36
+ "numpad_9": e.KEY_KP9,
37
+ "numpad_enter": e.KEY_ENTER,
38
+ "numpad_decimal": e.KEY_KPDOT,
39
+ "numpad_divide": e.KEY_KPSLASH,
40
+ "numpad_multiply": e.KEY_KPASTERISK,
41
+ "numpad_subtract": e.KEY_KPMINUS,
42
+ "numpad_add": e.KEY_KPPLUS,
43
+ }
44
+ _OLD_PYNPUT_KEYS: Dict[str, int] = {
45
+ "media_volume_mute": e.KEY_MUTE,
46
+ "media_volume_down": e.KEY_VOLUMEDOWN,
47
+ "media_volume_up": e.KEY_VOLUMEUP,
48
+ "media_play_pause": e.KEY_PLAYPAUSE,
49
+ "media_previous_track": e.KEY_PREVIOUSSONG,
50
+ "media_previous": e.KEY_PREVIOUSSONG,
51
+ "media_next_track": e.KEY_NEXTSONG,
52
+ "media_next": e.KEY_NEXTSONG,
53
+ "media_stop": e.KEY_STOPCD,
54
+ "num_lock": e.KEY_NUMLOCK,
55
+ "caps_lock": e.KEY_CAPSLOCK,
56
+ "scroll_lock": e.KEY_SCROLLLOCK,
57
+ }
58
+ _MODIFIER_KEYS: Dict[str, int] = {
59
+ "ctrl": e.KEY_LEFTCTRL,
60
+ "alt": e.KEY_LEFTALT,
61
+ "alt_gr": e.KEY_RIGHTALT,
62
+ "shift": e.KEY_LEFTSHIFT,
63
+ "meta": e.KEY_LEFTMETA,
64
+ "super": e.KEY_LEFTMETA,
65
+ "win": e.KEY_LEFTMETA,
66
+ }
67
+
68
+ _BAD_ECODES = ['KEY_MAX', 'KEY_CNT']
69
+ _KEY_MAPPING: Dict[str, int] = {
70
+ 'a': e.KEY_A,
71
+ 'b': e.KEY_B,
72
+ 'c': e.KEY_C,
73
+ 'd': e.KEY_D,
74
+ 'e': e.KEY_E,
75
+ 'f': e.KEY_F,
76
+ 'g': e.KEY_G,
77
+ 'h': e.KEY_H,
78
+ 'i': e.KEY_I,
79
+ 'j': e.KEY_J,
80
+ 'k': e.KEY_K,
81
+ 'l': e.KEY_L,
82
+ 'm': e.KEY_M,
83
+ 'n': e.KEY_N,
84
+ 'o': e.KEY_O,
85
+ 'p': e.KEY_P,
86
+ 'q': e.KEY_Q,
87
+ 'r': e.KEY_R,
88
+ 's': e.KEY_S,
89
+ 't': e.KEY_T,
90
+ 'u': e.KEY_U,
91
+ 'v': e.KEY_V,
92
+ 'w': e.KEY_W,
93
+ 'x': e.KEY_X,
94
+ 'y': e.KEY_Y,
95
+ 'z': e.KEY_Z,
96
+ 'A': e.KEY_A,
97
+ 'B': e.KEY_B,
98
+ 'C': e.KEY_C,
99
+ 'D': e.KEY_D,
100
+ 'E': e.KEY_E,
101
+ 'F': e.KEY_F,
102
+ 'G': e.KEY_G,
103
+ 'H': e.KEY_H,
104
+ 'I': e.KEY_I,
105
+ 'J': e.KEY_J,
106
+ 'K': e.KEY_K,
107
+ 'L': e.KEY_L,
108
+ 'M': e.KEY_M,
109
+ 'N': e.KEY_N,
110
+ 'O': e.KEY_O,
111
+ 'P': e.KEY_P,
112
+ 'Q': e.KEY_Q,
113
+ 'R': e.KEY_R,
114
+ 'S': e.KEY_S,
115
+ 'T': e.KEY_T,
116
+ 'U': e.KEY_U,
117
+ 'V': e.KEY_V,
118
+ 'W': e.KEY_W,
119
+ 'X': e.KEY_X,
120
+ 'Y': e.KEY_Y,
121
+ 'Z': e.KEY_Z,
122
+ '1': e.KEY_1,
123
+ '2': e.KEY_2,
124
+ '3': e.KEY_3,
125
+ '4': e.KEY_4,
126
+ '5': e.KEY_5,
127
+ '6': e.KEY_6,
128
+ '7': e.KEY_7,
129
+ '8': e.KEY_8,
130
+ '9': e.KEY_9,
131
+ '0': e.KEY_0,
132
+ '-': e.KEY_MINUS,
133
+ '=': e.KEY_EQUAL,
134
+ '[': e.KEY_LEFTBRACE,
135
+ ']': e.KEY_RIGHTBRACE,
136
+ '\\': e.KEY_BACKSLASH,
137
+ ';': e.KEY_SEMICOLON,
138
+ "'": e.KEY_APOSTROPHE,
139
+ ',': e.KEY_COMMA,
140
+ '.': e.KEY_DOT,
141
+ '/': e.KEY_SLASH,
142
+ ' ': e.KEY_SPACE,
143
+ '\n': e.KEY_ENTER,
144
+ '\t': e.KEY_TAB,
145
+ '`': e.KEY_GRAVE,
146
+ '!': e.KEY_1,
147
+ '@': e.KEY_2,
148
+ '#': e.KEY_3,
149
+ '$': e.KEY_4,
150
+ '%': e.KEY_5,
151
+ '^': e.KEY_6,
152
+ '&': e.KEY_7,
153
+ '*': e.KEY_8,
154
+ '(': e.KEY_9,
155
+ ')': e.KEY_0,
156
+ '_': e.KEY_MINUS,
157
+ '+': e.KEY_EQUAL,
158
+ '{': e.KEY_LEFTBRACE,
159
+ '}': e.KEY_RIGHTBRACE,
160
+ '|': e.KEY_BACKSLASH,
161
+ ':': e.KEY_SEMICOLON,
162
+ '"': e.KEY_APOSTROPHE,
163
+ '<': e.KEY_COMMA,
164
+ '>': e.KEY_DOT,
165
+ '?': e.KEY_SLASH,
166
+ '~': e.KEY_GRAVE,
167
+ }
168
+ _SHIFT_KEY_MAPPING: Dict[str, int] = {
169
+ '!': e.KEY_1,
170
+ '@': e.KEY_2,
171
+ '#': e.KEY_3,
172
+ '$': e.KEY_4,
173
+ '%': e.KEY_5,
174
+ '^': e.KEY_6,
175
+ '&': e.KEY_7,
176
+ '*': e.KEY_8,
177
+ '(': e.KEY_9,
178
+ ')': e.KEY_0,
179
+ '_': e.KEY_MINUS,
180
+ '+': e.KEY_EQUAL,
181
+ '{': e.KEY_LEFTBRACE,
182
+ '}': e.KEY_RIGHTBRACE,
183
+ '|': e.KEY_BACKSLASH,
184
+ ':': e.KEY_SEMICOLON,
185
+ '"': e.KEY_APOSTROPHE,
186
+ '<': e.KEY_COMMA,
187
+ '>': e.KEY_DOT,
188
+ '?': e.KEY_SLASH,
189
+ '~': e.KEY_GRAVE,
190
+ 'A': e.KEY_A,
191
+ 'B': e.KEY_B,
192
+ 'C': e.KEY_C,
193
+ 'D': e.KEY_D,
194
+ 'E': e.KEY_E,
195
+ 'F': e.KEY_F,
196
+ 'G': e.KEY_G,
197
+ 'H': e.KEY_H,
198
+ 'I': e.KEY_I,
199
+ 'J': e.KEY_J,
200
+ 'K': e.KEY_K,
201
+ 'L': e.KEY_L,
202
+ 'M': e.KEY_M,
203
+ 'N': e.KEY_N,
204
+ 'O': e.KEY_O,
205
+ 'P': e.KEY_P,
206
+ 'Q': e.KEY_Q,
207
+ 'R': e.KEY_R,
208
+ 'S': e.KEY_S,
209
+ 'T': e.KEY_T,
210
+ 'U': e.KEY_U,
211
+ 'V': e.KEY_V,
212
+ 'W': e.KEY_W,
213
+ 'X': e.KEY_X,
214
+ 'Y': e.KEY_Y,
215
+ 'Z': e.KEY_Z,
216
+ }
217
+ # we remove KEY_ from the key names to make it easier to type
218
+ _SUPPORTED_KEYS = [key.replace("KEY_", "").lower() for key in dir(e) if key.startswith("KEY_") and key not in _BAD_ECODES]
219
+ _SUPPORTED_KEY_CONSTANTS = [value for name, value in vars(e).items() if name.startswith('KEY_') and name not in _BAD_ECODES]
220
+ # fmt: on
221
+
222
+
223
+ # Initialize UInput in a global variable so that we don't initialize each time a key is pressed
224
+ class UInputWrapper:
225
+ def __init__(self):
226
+ self.initialized = False
227
+ self.device = None
228
+
229
+ def initialize(self):
230
+ if not self.initialized:
231
+ print("Initializing UInput...")
232
+ self.device = UInput({e.EV_KEY: _SUPPORTED_KEY_CONSTANTS})
233
+ self.initialized = True
234
+
235
+
236
+ _UINPUT = UInputWrapper()
237
+
238
+
239
+ def parse_delay(key: Union[str, int]) -> Union[str, int]:
240
+ if isinstance(key, int) or not key.startswith("delay"):
241
+ return key
242
+ key = key.replace("delay", "")
243
+ if len(key) == 0:
244
+ return _DELAY_KEYSYM + _DEFAULT_ADDITIONAL_DELAY
245
+ delay = _DEFAULT_ADDITIONAL_DELAY
246
+ try:
247
+ delay = int(float(key) * 10)
248
+ except ValueError:
249
+ print("Cannot parse delay amount, using default delay")
250
+ return _DELAY_KEYSYM + delay
251
+
252
+
253
+ def parse_keys(
254
+ key: Union[str, int], key_type: Union[Dict[str, int], Dict[str, str]]) -> Union[str, int]: # fmt: skip
255
+ if isinstance(key, int):
256
+ return key
257
+ return key_type.get(key, key)
258
+
259
+
260
+ def parse_keys_as_keycodes(keys: str) -> List[List[Union[str, int]]]:
261
+ stripped = keys.strip().replace(" ", "").lower()
262
+ if not stripped:
263
+ return []
264
+ # split by , for sections
265
+ sections = stripped.split(",")
266
+ parsed_keys = []
267
+ for section in sections:
268
+ # split by + for individual keys
269
+ individual = section.split("+")
270
+ # filter empty strings
271
+ individual = list(filter(None, individual))
272
+ # replace any string with e.KEY_<string>
273
+ individual = [getattr(e, f"KEY_{key.upper()}", key) for key in individual]
274
+ # check if delay
275
+ parsed: List[Union[str, int]] = [parse_delay(key) for key in individual]
276
+ # replace special keys
277
+ parsed = [parse_keys(key, _SPECIAL_KEYS) for key in parsed]
278
+ # replace old numpad keys
279
+ parsed = [parse_keys(key, _OLD_NUMPAD_KEYS) for key in parsed]
280
+ # replace old media keys
281
+ parsed = [parse_keys(key, _OLD_PYNPUT_KEYS) for key in parsed]
282
+ # replace modifier keys
283
+ parsed = [parse_keys(key, _MODIFIER_KEYS) for key in parsed]
284
+ # replace key names with key codes
285
+ parsed = [parse_keys(key, _KEY_MAPPING) for key in parsed]
286
+
287
+ # if any value is not an int, raise an error
288
+ if not all(isinstance(key, int) for key in parsed):
289
+ invalid_keys = [key for key in parsed if not isinstance(key, int)]
290
+ raise ValueError(f"Invalid keys: {invalid_keys}")
291
+
292
+ if len(parsed) > 0:
293
+ parsed_keys.append(parsed)
294
+
295
+ return parsed_keys
296
+
297
+
298
+ def keyboard_write(string: str):
299
+ _UINPUT.initialize()
300
+ _ui = _UINPUT.device
301
+ caps_lock_is_on = check_caps_lock()
302
+ for char in string:
303
+ is_unicode = False
304
+ unicode_bytes = char.encode("unicode_escape")
305
+ # '\u' or '\U' for unicode, or '\x' for UTF-8
306
+ if unicode_bytes[0] == 92 and unicode_bytes[1] in [85, 117, 120]:
307
+ is_unicode = True
308
+
309
+ if char in _KEY_MAPPING:
310
+ keycode = _KEY_MAPPING[char]
311
+ need_shift = False
312
+
313
+ if char in _SHIFT_KEY_MAPPING:
314
+ need_shift = True
315
+
316
+ if char.isalpha() and caps_lock_is_on:
317
+ need_shift = not need_shift
318
+
319
+ if need_shift:
320
+ _ui.write(e.EV_KEY, e.KEY_LEFTSHIFT, 1)
321
+
322
+ _ui.write(e.EV_KEY, keycode, 1)
323
+ _ui.write(e.EV_KEY, keycode, 0)
324
+
325
+ if need_shift:
326
+ _ui.write(e.EV_KEY, e.KEY_LEFTSHIFT, 0)
327
+
328
+ # send keys
329
+ _ui.syn()
330
+ time.sleep(_DEFAULT_KEY_PRESS_DELAY)
331
+ elif is_unicode:
332
+ unicode_hex = hex(int(unicode_bytes[2:], 16))
333
+ unicode_hex_keys = unicode_hex[2:]
334
+
335
+ # hold shift + ctrl
336
+ _ui.write(e.EV_KEY, e.KEY_LEFTSHIFT, 1)
337
+ _ui.write(e.EV_KEY, e.KEY_LEFTCTRL, 1)
338
+
339
+ # press 'U' to initiate unicode sequence
340
+ _ui.write(e.EV_KEY, e.KEY_U, 1)
341
+ _ui.write(e.EV_KEY, e.KEY_U, 0)
342
+
343
+ # press unicode codepoint keys
344
+ for hex_char in unicode_hex_keys:
345
+ keycode = _KEY_MAPPING[hex_char]
346
+ _ui.write(e.EV_KEY, keycode, 1)
347
+ _ui.write(e.EV_KEY, keycode, 0)
348
+
349
+ # release shift + ctrl
350
+ _ui.write(e.EV_KEY, e.KEY_LEFTSHIFT, 0)
351
+ _ui.write(e.EV_KEY, e.KEY_LEFTCTRL, 0)
352
+
353
+ # send keys
354
+ _ui.syn()
355
+ else:
356
+ print(f"Unsupported character: {char}")
357
+
358
+
359
+ _PRESS_KEY_THREADS: List[QThread] = []
360
+
361
+
362
+ class KeyboardThread(QThread):
363
+ def __init__(self, keys):
364
+ super().__init__()
365
+ self.keys = keys
366
+
367
+ def run(self):
368
+ _UINPUT.initialize()
369
+ _ui = _UINPUT.device
370
+ sections = parse_keys_as_keycodes(self.keys)
371
+ for section_of_keycodes in sections:
372
+ for keycode in section_of_keycodes:
373
+ if keycode > _DELAY_KEYSYM:
374
+ # if it is a delay, subtract the delay keysym from the keycode to get the delay in seconds
375
+ time.sleep((keycode - _DELAY_KEYSYM) / 10.0)
376
+ continue
377
+ _ui.write(e.EV_KEY, keycode, 1)
378
+ _ui.syn()
379
+ time.sleep(_DEFAULT_KEY_PRESS_DELAY)
380
+
381
+ for keycode in reversed(section_of_keycodes):
382
+ _ui.write(e.EV_KEY, keycode, 0)
383
+ _ui.syn()
384
+
385
+ # add some delay between sections, only if there are more than one
386
+ if len(section_of_keycodes) > 1:
387
+ time.sleep(_DEFAULT_KEY_SECTION_DELAY)
388
+
389
+
390
+ def cleanup_keyboard_thread():
391
+ global _PRESS_KEY_THREADS
392
+ # Remove threads that are not running anymore
393
+ _PRESS_KEY_THREADS = [t for t in _PRESS_KEY_THREADS if t.isRunning()]
394
+
395
+
396
+ def keyboard_press_keys(keys: str):
397
+ global _PRESS_KEY_THREADS # noqa: F824
398
+ thread = KeyboardThread(keys)
399
+ thread.finished.connect(cleanup_keyboard_thread)
400
+ _PRESS_KEY_THREADS.append(thread)
401
+ thread.start()
402
+
403
+
404
+ def get_valid_key_names() -> List[str]:
405
+ """Returns a list of valid key names."""
406
+ key_names = [key for key in _SUPPORTED_KEYS]
407
+ key_names.extend(_SPECIAL_KEYS.keys())
408
+ key_names.extend(_OLD_NUMPAD_KEYS.keys())
409
+ key_names.extend(_OLD_PYNPUT_KEYS.keys())
410
+ key_names.extend(_MODIFIER_KEYS.keys())
411
+ return sorted(key_names)
412
+
413
+
414
+ def check_caps_lock() -> bool:
415
+ """Returns True if Caps Lock is on, False if it is off, and False if it cannot be determined."""
416
+ devices = [InputDevice(path) for path in list_devices()]
417
+ for device in devices:
418
+ if device.capabilities().get(e.EV_LED):
419
+ return e.LED_CAPSL in device.leds()
420
+ return False
421
+
422
+
423
+ class KeyPressAutoComplete(QCompleter):
424
+ special_keys = _SPECIAL_KEYS.values()
425
+ allowed_keys = get_valid_key_names()
426
+
427
+ def __init__(self, parent=None):
428
+ super(KeyPressAutoComplete, self).__init__(parent)
429
+ model = QStringListModel()
430
+ model.setStringList(self.allowed_keys)
431
+ self.setModel(model)
432
+ self.setCompletionMode(QCompleter.CompletionMode.PopupCompletion)
433
+
434
+ def update_prefix(self, text: str):
435
+ """Update the prefix for the autocompletion."""
436
+ # space " " is considered a special key in case user types, for example, "ctrl + "
437
+ # we still can autocomplete after the space
438
+ last_special_index = max(text.rfind(","), text.rfind("+"), text.rfind(" "))
439
+ # if there is a special key, update model to allow autocomplete for further keys
440
+ if last_special_index != -1:
441
+ prefix = text[: last_special_index + 1]
442
+ allowed_keys = [prefix + key for key in self.allowed_keys]
443
+ self.model().setStringList(allowed_keys) # type: ignore [attr-defined]
444
+ # otherwise, reset model to allow autocomplete for all keys
445
+ else:
446
+ self.model().setStringList(self.allowed_keys) # type: ignore [attr-defined]
447
+ self.complete()
File without changes