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.
- streamdeck_gui_ng-4.1.3.dist-info/METADATA +141 -0
- streamdeck_gui_ng-4.1.3.dist-info/RECORD +62 -0
- streamdeck_gui_ng-4.1.3.dist-info/WHEEL +4 -0
- streamdeck_gui_ng-4.1.3.dist-info/entry_points.txt +4 -0
- streamdeck_gui_ng-4.1.3.dist-info/licenses/LICENSE +21 -0
- streamdeck_ui/__init__.py +6 -0
- streamdeck_ui/api.py +712 -0
- streamdeck_ui/button.ui +1214 -0
- streamdeck_ui/cli/__init__.py +0 -0
- streamdeck_ui/cli/commands.py +191 -0
- streamdeck_ui/cli/server.py +292 -0
- streamdeck_ui/config.py +244 -0
- streamdeck_ui/dimmer.py +93 -0
- streamdeck_ui/display/__init__.py +0 -0
- streamdeck_ui/display/background_color_filter.py +41 -0
- streamdeck_ui/display/display_grid.py +265 -0
- streamdeck_ui/display/empty_filter.py +43 -0
- streamdeck_ui/display/filter.py +65 -0
- streamdeck_ui/display/image_filter.py +144 -0
- streamdeck_ui/display/keypress_filter.py +63 -0
- streamdeck_ui/display/pipeline.py +74 -0
- streamdeck_ui/display/pulse_filter.py +54 -0
- streamdeck_ui/display/text_filter.py +142 -0
- streamdeck_ui/fonts/roboto/LICENSE.txt +202 -0
- streamdeck_ui/fonts/roboto/Roboto-Black.ttf +0 -0
- streamdeck_ui/fonts/roboto/Roboto-BlackItalic.ttf +0 -0
- streamdeck_ui/fonts/roboto/Roboto-Bold.ttf +0 -0
- streamdeck_ui/fonts/roboto/Roboto-BoldItalic.ttf +0 -0
- streamdeck_ui/fonts/roboto/Roboto-Italic.ttf +0 -0
- streamdeck_ui/fonts/roboto/Roboto-Light.ttf +0 -0
- streamdeck_ui/fonts/roboto/Roboto-LightItalic.ttf +0 -0
- streamdeck_ui/fonts/roboto/Roboto-Medium.ttf +0 -0
- streamdeck_ui/fonts/roboto/Roboto-MediumItalic.ttf +0 -0
- streamdeck_ui/fonts/roboto/Roboto-Regular.ttf +0 -0
- streamdeck_ui/fonts/roboto/Roboto-Thin.ttf +0 -0
- streamdeck_ui/fonts/roboto/Roboto-ThinItalic.ttf +0 -0
- streamdeck_ui/gui.py +1423 -0
- streamdeck_ui/icons/add_page.png +0 -0
- streamdeck_ui/icons/cross.png +0 -0
- streamdeck_ui/icons/gear.png +0 -0
- streamdeck_ui/icons/horizontal-align.png +0 -0
- streamdeck_ui/icons/remove_page.png +0 -0
- streamdeck_ui/icons/vertical-align.png +0 -0
- streamdeck_ui/icons/warning_icon_button.png +0 -0
- streamdeck_ui/logger.py +11 -0
- streamdeck_ui/logo.png +0 -0
- streamdeck_ui/main.ui +407 -0
- streamdeck_ui/mock_streamdeck.py +204 -0
- streamdeck_ui/model.py +78 -0
- streamdeck_ui/modules/__init__.py +0 -0
- streamdeck_ui/modules/fonts.py +150 -0
- streamdeck_ui/modules/keyboard.py +447 -0
- streamdeck_ui/modules/utils/__init__.py +0 -0
- streamdeck_ui/modules/utils/timers.py +35 -0
- streamdeck_ui/resources.qrc +10 -0
- streamdeck_ui/resources_rc.py +324 -0
- streamdeck_ui/semaphore.py +38 -0
- streamdeck_ui/settings.ui +155 -0
- streamdeck_ui/stream_deck_monitor.py +157 -0
- streamdeck_ui/ui_button.py +421 -0
- streamdeck_ui/ui_main.py +267 -0
- 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
|