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/gui.py
ADDED
|
@@ -0,0 +1,1423 @@
|
|
|
1
|
+
"""Defines the QT powered interface for configuring Stream Decks"""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import shlex
|
|
5
|
+
import signal
|
|
6
|
+
import sys
|
|
7
|
+
from functools import partial
|
|
8
|
+
from subprocess import Popen # nosec - Need to allow users to specify arbitrary commands
|
|
9
|
+
from typing import Dict, List, Optional, Union
|
|
10
|
+
|
|
11
|
+
from importlib_metadata import PackageNotFoundError, version
|
|
12
|
+
from PySide6.QtCore import QMimeData, QSettings, QSignalBlocker, QSize, Qt, QTimer, QUrl
|
|
13
|
+
from PySide6.QtGui import QAction, QDesktopServices, QDrag, QFont, QIcon, QPalette
|
|
14
|
+
from PySide6.QtWidgets import (
|
|
15
|
+
QApplication,
|
|
16
|
+
QColorDialog,
|
|
17
|
+
QDialog,
|
|
18
|
+
QFileDialog,
|
|
19
|
+
QGridLayout,
|
|
20
|
+
QHBoxLayout,
|
|
21
|
+
QMainWindow,
|
|
22
|
+
QMenu,
|
|
23
|
+
QMessageBox,
|
|
24
|
+
QSizePolicy,
|
|
25
|
+
QSystemTrayIcon,
|
|
26
|
+
QToolButton,
|
|
27
|
+
QVBoxLayout,
|
|
28
|
+
QWidget,
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
from streamdeck_ui.api import StreamDeckServer
|
|
32
|
+
from streamdeck_ui.cli.server import CLIStreamDeckServer
|
|
33
|
+
from streamdeck_ui.config import (
|
|
34
|
+
APP_LOGO,
|
|
35
|
+
APP_NAME,
|
|
36
|
+
DEFAULT_BACKGROUND_COLOR,
|
|
37
|
+
DEFAULT_FONT_COLOR,
|
|
38
|
+
DEFAULT_FONT_FALLBACK_PATH,
|
|
39
|
+
DEFAULT_FONT_SIZE,
|
|
40
|
+
STATE_FILE,
|
|
41
|
+
STATE_FILE_BACKUP,
|
|
42
|
+
config_file_need_migration,
|
|
43
|
+
do_config_file_migration,
|
|
44
|
+
)
|
|
45
|
+
from streamdeck_ui.display.text_filter import is_a_valid_text_filter_font
|
|
46
|
+
from streamdeck_ui.modules.fonts import DEFAULT_FONT_FAMILY, FONTS_DICT, find_font_info
|
|
47
|
+
from streamdeck_ui.modules.keyboard import KeyPressAutoComplete, keyboard_press_keys, keyboard_write
|
|
48
|
+
from streamdeck_ui.modules.utils.timers import debounce
|
|
49
|
+
from streamdeck_ui.semaphore import Semaphore, SemaphoreAcquireError
|
|
50
|
+
from streamdeck_ui.ui_button import Ui_ButtonForm
|
|
51
|
+
from streamdeck_ui.ui_main import Ui_MainWindow
|
|
52
|
+
from streamdeck_ui.ui_settings import Ui_SettingsDialog
|
|
53
|
+
|
|
54
|
+
# this ignore is just a workaround to set api with something
|
|
55
|
+
# and be able to test
|
|
56
|
+
api: StreamDeckServer = StreamDeckServer()
|
|
57
|
+
|
|
58
|
+
main_window: "MainWindow"
|
|
59
|
+
"Reference to the main window, used across multiple functions"
|
|
60
|
+
|
|
61
|
+
last_image_dir: str = ""
|
|
62
|
+
"Stores the last direction where user selected an image from"
|
|
63
|
+
|
|
64
|
+
selected_button: Optional[QToolButton] = None
|
|
65
|
+
"A reference to the currently selected button"
|
|
66
|
+
|
|
67
|
+
text_update_timer: Optional[QTimer] = None
|
|
68
|
+
"Timer used to delay updates to the button text"
|
|
69
|
+
|
|
70
|
+
BUTTON_STYLE = """
|
|
71
|
+
QToolButton {
|
|
72
|
+
margin: 2px;
|
|
73
|
+
border: 2px solid #444444;
|
|
74
|
+
border-radius: 8px;
|
|
75
|
+
background-color: #000000;
|
|
76
|
+
border-style: outset;}
|
|
77
|
+
QToolButton:checked {
|
|
78
|
+
margin: 2px;
|
|
79
|
+
border: 2px solid #cccccc;
|
|
80
|
+
border-radius: 8px;
|
|
81
|
+
background-color: #000000;
|
|
82
|
+
border-style: outset;}
|
|
83
|
+
"""
|
|
84
|
+
|
|
85
|
+
BUTTON_DRAG_STYLE = """
|
|
86
|
+
QToolButton {
|
|
87
|
+
margin: 2px;
|
|
88
|
+
border: 2px solid #999999;
|
|
89
|
+
border-radius: 8px;
|
|
90
|
+
background-color: #000000;
|
|
91
|
+
border-style: outset;}
|
|
92
|
+
"""
|
|
93
|
+
|
|
94
|
+
DEVICE_PAGE_STYLE = """
|
|
95
|
+
background-color: black
|
|
96
|
+
"""
|
|
97
|
+
|
|
98
|
+
dimmer_options = {
|
|
99
|
+
"Never": 0,
|
|
100
|
+
"10 Seconds": 10,
|
|
101
|
+
"1 Minute": 60,
|
|
102
|
+
"5 Minutes": 300,
|
|
103
|
+
"10 Minutes": 600,
|
|
104
|
+
"15 Minutes": 900,
|
|
105
|
+
"30 Minutes": 1800,
|
|
106
|
+
"1 Hour": 3600,
|
|
107
|
+
"5 Hours": 7200,
|
|
108
|
+
"10 Hours": 36000,
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
class DraggableButton(QToolButton):
|
|
113
|
+
"""A QToolButton that supports drag and drop and swaps the button properties on drop"""
|
|
114
|
+
|
|
115
|
+
def __init__(self, parent, ui, api_: StreamDeckServer):
|
|
116
|
+
super(DraggableButton, self).__init__(parent)
|
|
117
|
+
|
|
118
|
+
self.setAcceptDrops(True)
|
|
119
|
+
self.ui = ui
|
|
120
|
+
self.api = api_
|
|
121
|
+
|
|
122
|
+
def mouseMoveEvent(self, e): # noqa: N802 - Part of QT signature.
|
|
123
|
+
if e.buttons() != Qt.LeftButton:
|
|
124
|
+
return
|
|
125
|
+
|
|
126
|
+
self.api.reset_dimmer(_deck())
|
|
127
|
+
|
|
128
|
+
mime_data = QMimeData()
|
|
129
|
+
drag = QDrag(self)
|
|
130
|
+
drag.setMimeData(mime_data)
|
|
131
|
+
drag.exec(Qt.MoveAction)
|
|
132
|
+
|
|
133
|
+
def dropEvent(self, e): # noqa: N802 - Part of QT signature.
|
|
134
|
+
global selected_button
|
|
135
|
+
|
|
136
|
+
self.setStyleSheet(BUTTON_STYLE)
|
|
137
|
+
deck_id = _deck()
|
|
138
|
+
page_id = _page()
|
|
139
|
+
|
|
140
|
+
index = self.property("index")
|
|
141
|
+
if e.source():
|
|
142
|
+
source_index = e.source().property("index")
|
|
143
|
+
# Ignore drag and drop on yourself
|
|
144
|
+
if source_index == index:
|
|
145
|
+
return
|
|
146
|
+
|
|
147
|
+
self.api.swap_buttons(deck_id, page_id, source_index, index)
|
|
148
|
+
# In the case that we've dragged the currently selected button, we have to
|
|
149
|
+
# check the target button instead, so it appears that it followed the drag/drop
|
|
150
|
+
if e.source().isChecked():
|
|
151
|
+
e.source().setChecked(False)
|
|
152
|
+
self.setChecked(True)
|
|
153
|
+
selected_button = self
|
|
154
|
+
else:
|
|
155
|
+
# Handle drag and drop from outside the application
|
|
156
|
+
if e.mimeData().hasUrls:
|
|
157
|
+
file_name = e.mimeData().urls()[0].toLocalFile()
|
|
158
|
+
self.api.set_button_icon(deck_id, page_id, index, file_name)
|
|
159
|
+
|
|
160
|
+
if e.source():
|
|
161
|
+
source_index = e.source().property("index")
|
|
162
|
+
icon = self.api.get_button_icon_pixmap(deck_id, page_id, source_index)
|
|
163
|
+
if icon:
|
|
164
|
+
e.source().setIcon(icon)
|
|
165
|
+
|
|
166
|
+
icon = self.api.get_button_icon_pixmap(deck_id, page_id, index)
|
|
167
|
+
if icon:
|
|
168
|
+
self.setIcon(icon)
|
|
169
|
+
|
|
170
|
+
def dragEnterEvent(self, e): # noqa: N802 - Part of QT signature.
|
|
171
|
+
if type(self) is DraggableButton:
|
|
172
|
+
e.setAccepted(True)
|
|
173
|
+
self.setStyleSheet(BUTTON_DRAG_STYLE)
|
|
174
|
+
else:
|
|
175
|
+
e.setAccepted(False)
|
|
176
|
+
|
|
177
|
+
def dragLeaveEvent(self, e): # noqa: N802 - Part of QT signature.
|
|
178
|
+
self.setStyleSheet(BUTTON_STYLE)
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def handle_keypress(ui, deck_id: str, key: int, state: bool) -> None:
|
|
182
|
+
# TODO: Handle both key down and key up events in future.
|
|
183
|
+
if state:
|
|
184
|
+
if api.reset_dimmer(deck_id):
|
|
185
|
+
return
|
|
186
|
+
|
|
187
|
+
page = api.get_page(deck_id)
|
|
188
|
+
command = api.get_button_command(deck_id, page, key)
|
|
189
|
+
keys = api.get_button_keys(deck_id, page, key)
|
|
190
|
+
write = api.get_button_write(deck_id, page, key)
|
|
191
|
+
brightness_change = api.get_button_change_brightness(deck_id, page, key)
|
|
192
|
+
switch_page = api.get_button_switch_page(deck_id, page, key)
|
|
193
|
+
switch_state = api.get_button_switch_state(deck_id, page, key)
|
|
194
|
+
|
|
195
|
+
if command:
|
|
196
|
+
try:
|
|
197
|
+
Popen(shlex.split(command)) # nosec, need to allow execution of arbitrary commands
|
|
198
|
+
# Force refresh icon after command execution if configured
|
|
199
|
+
if api.get_button_force_refresh(deck_id, page, key):
|
|
200
|
+
api._update_button_filters(deck_id, page, key)
|
|
201
|
+
display_handler = api.display_handlers[deck_id]
|
|
202
|
+
display_handler.synchronize()
|
|
203
|
+
except Exception as error:
|
|
204
|
+
print(f"The command '{command}' failed: {error}")
|
|
205
|
+
show_tray_warning_message("The command failed to execute.")
|
|
206
|
+
|
|
207
|
+
if keys:
|
|
208
|
+
try:
|
|
209
|
+
keyboard_press_keys(keys)
|
|
210
|
+
except Exception as error:
|
|
211
|
+
print(f"Could not press keys '{keys}': {error}")
|
|
212
|
+
show_tray_warning_message(f"Unable to perform key press action. {error}")
|
|
213
|
+
|
|
214
|
+
if write:
|
|
215
|
+
try:
|
|
216
|
+
keyboard_write(write)
|
|
217
|
+
except Exception as error:
|
|
218
|
+
print(f"Could not complete the write command: {error}")
|
|
219
|
+
show_tray_warning_message("Unable to perform write action.")
|
|
220
|
+
|
|
221
|
+
if brightness_change:
|
|
222
|
+
try:
|
|
223
|
+
api.change_brightness(deck_id, brightness_change)
|
|
224
|
+
except Exception as error:
|
|
225
|
+
print(f"Could not change brightness: {error}")
|
|
226
|
+
show_tray_warning_message("Unable to change brightness.")
|
|
227
|
+
|
|
228
|
+
if switch_page:
|
|
229
|
+
switch_page_index = switch_page - 1
|
|
230
|
+
if switch_page_index in api.get_pages(deck_id):
|
|
231
|
+
api.set_page(deck_id, switch_page_index)
|
|
232
|
+
if _deck() == deck_id:
|
|
233
|
+
for page in range(ui.pages.count()):
|
|
234
|
+
if ui.pages.widget(page).property("page_id") == switch_page_index:
|
|
235
|
+
ui.pages.setCurrentIndex(page)
|
|
236
|
+
break
|
|
237
|
+
else:
|
|
238
|
+
show_tray_warning_message(
|
|
239
|
+
f"Unable to perform switch page, the page {switch_page} does not exist in your current settings" # noqa: E713
|
|
240
|
+
)
|
|
241
|
+
|
|
242
|
+
if switch_state:
|
|
243
|
+
switch_state_index = switch_state - 1
|
|
244
|
+
if switch_state_index in api.get_button_states(deck_id, page, key):
|
|
245
|
+
api.set_button_state(deck_id, page, key, switch_state_index)
|
|
246
|
+
if _deck() == deck_id:
|
|
247
|
+
if _button() == key:
|
|
248
|
+
for button_state in range(ui.button_states.count()):
|
|
249
|
+
if ui.button_states.widget(button_state).property("button_state_id") == switch_state_index:
|
|
250
|
+
ui.button_states.setCurrentIndex(button_state)
|
|
251
|
+
break
|
|
252
|
+
redraw_button(key)
|
|
253
|
+
else:
|
|
254
|
+
show_tray_warning_message(
|
|
255
|
+
f"Unable to perform switch button state, the button state {switch_state} does not exist in your current settings" # noqa: E713
|
|
256
|
+
)
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def _deck() -> Optional[str]:
|
|
260
|
+
"""Returns the currently selected Stream Deck serial number"""
|
|
261
|
+
if main_window.ui.device_list.count() == 0:
|
|
262
|
+
return None
|
|
263
|
+
return main_window.ui.device_list.itemData(main_window.ui.device_list.currentIndex())
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def _page() -> Optional[int]:
|
|
267
|
+
"""Returns the currently selected page index"""
|
|
268
|
+
tab_index = main_window.ui.pages.currentIndex()
|
|
269
|
+
page = main_window.ui.pages.widget(tab_index)
|
|
270
|
+
if page is None:
|
|
271
|
+
return None
|
|
272
|
+
return page.property("page_id")
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
def _button() -> Optional[int]:
|
|
276
|
+
"""Returns the currently selected button index"""
|
|
277
|
+
if selected_button is None:
|
|
278
|
+
return None
|
|
279
|
+
index = selected_button.property("index")
|
|
280
|
+
|
|
281
|
+
if index < 0:
|
|
282
|
+
return None
|
|
283
|
+
|
|
284
|
+
return index
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
def _button_state() -> Optional[int]:
|
|
288
|
+
"""Returns the currently selected button state index"""
|
|
289
|
+
tab_index = main_window.ui.button_states.currentIndex()
|
|
290
|
+
state = main_window.ui.button_states.widget(tab_index)
|
|
291
|
+
return state.property("button_state_id")
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
def handle_change_page() -> None:
|
|
295
|
+
"""Change the Stream Deck to the desired page and update
|
|
296
|
+
the on-screen buttons.
|
|
297
|
+
"""
|
|
298
|
+
global selected_button
|
|
299
|
+
|
|
300
|
+
if selected_button:
|
|
301
|
+
selected_button.setChecked(False)
|
|
302
|
+
selected_button = None
|
|
303
|
+
|
|
304
|
+
deck_id = _deck()
|
|
305
|
+
page_id = _page()
|
|
306
|
+
if deck_id is not None and page_id is not None:
|
|
307
|
+
api.set_page(deck_id, page_id)
|
|
308
|
+
redraw_buttons()
|
|
309
|
+
api.reset_dimmer(deck_id)
|
|
310
|
+
build_button_state_pages()
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
def handle_change_button_state() -> None:
|
|
314
|
+
"""Change the Stream Deck to the desired button state and update
|
|
315
|
+
the on-screen buttons.
|
|
316
|
+
"""
|
|
317
|
+
deck_id = _deck()
|
|
318
|
+
page_id = _page()
|
|
319
|
+
button_id = _button()
|
|
320
|
+
button_state_id = _button_state()
|
|
321
|
+
if deck_id is not None and page_id is not None and button_id is not None and button_state_id is not None:
|
|
322
|
+
api.set_button_state(deck_id, page_id, button_id, button_state_id)
|
|
323
|
+
redraw_button(button_id)
|
|
324
|
+
api.reset_dimmer(deck_id)
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
def handle_new_page() -> None:
|
|
328
|
+
deck_id = _deck()
|
|
329
|
+
if not deck_id:
|
|
330
|
+
return
|
|
331
|
+
|
|
332
|
+
# Add the new page to the api
|
|
333
|
+
new_page_index = api.add_new_page(deck_id)
|
|
334
|
+
build_device(main_window.ui)
|
|
335
|
+
|
|
336
|
+
# look for the new page in the ui
|
|
337
|
+
for page in range(main_window.ui.pages.count()):
|
|
338
|
+
if main_window.ui.pages.widget(page).property("page_id") == new_page_index:
|
|
339
|
+
main_window.ui.pages.setCurrentIndex(page)
|
|
340
|
+
break
|
|
341
|
+
main_window.ui.remove_page.setEnabled(True)
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
def handle_delete_page_with_confirmation() -> None:
|
|
345
|
+
confirm = QMessageBox(main_window)
|
|
346
|
+
confirm.setWindowTitle("Delete Page")
|
|
347
|
+
confirm.setText("Are you sure you want to delete this page?")
|
|
348
|
+
confirm.setStandardButtons(QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No)
|
|
349
|
+
confirm.setIcon(QMessageBox.Icon.Question)
|
|
350
|
+
button = confirm.exec()
|
|
351
|
+
if button == QMessageBox.StandardButton.Yes:
|
|
352
|
+
handle_delete_page()
|
|
353
|
+
|
|
354
|
+
|
|
355
|
+
def handle_delete_page() -> None:
|
|
356
|
+
deck_id = _deck()
|
|
357
|
+
page_id = _page()
|
|
358
|
+
if deck_id is None or page_id is None:
|
|
359
|
+
return
|
|
360
|
+
|
|
361
|
+
pages = api.get_pages(deck_id)
|
|
362
|
+
if len(pages) == 1:
|
|
363
|
+
return
|
|
364
|
+
|
|
365
|
+
new_page = _closest_page(page_id, pages)
|
|
366
|
+
tab_index_to_move = -1
|
|
367
|
+
tab_index_to_remove = -1
|
|
368
|
+
for tab_index in range(main_window.ui.pages.count()):
|
|
369
|
+
tab = main_window.ui.pages.widget(tab_index)
|
|
370
|
+
if tab.property("page_id") == new_page:
|
|
371
|
+
tab_index_to_move = tab_index
|
|
372
|
+
if tab.property("page_id") == page_id:
|
|
373
|
+
tab_index_to_remove = tab_index
|
|
374
|
+
|
|
375
|
+
main_window.ui.pages.setCurrentIndex(tab_index_to_move)
|
|
376
|
+
api.remove_page(deck_id, page_id)
|
|
377
|
+
main_window.ui.pages.removeTab(tab_index_to_remove)
|
|
378
|
+
if main_window.ui.pages.count() == 1:
|
|
379
|
+
main_window.ui.remove_page.setEnabled(False)
|
|
380
|
+
|
|
381
|
+
|
|
382
|
+
def handle_new_button_state() -> None:
|
|
383
|
+
deck_id = _deck()
|
|
384
|
+
page_id = _page()
|
|
385
|
+
button_id = _button()
|
|
386
|
+
|
|
387
|
+
if deck_id is None or page_id is None or button_id is None:
|
|
388
|
+
return
|
|
389
|
+
|
|
390
|
+
new_button_state_index = api.add_new_button_state(deck_id, page_id, button_id)
|
|
391
|
+
build_button_state_pages()
|
|
392
|
+
|
|
393
|
+
for button_state in range(main_window.ui.button_states.count()):
|
|
394
|
+
if main_window.ui.button_states.widget(button_state).property("button_state_id") == new_button_state_index:
|
|
395
|
+
main_window.ui.button_states.setCurrentIndex(button_state)
|
|
396
|
+
break
|
|
397
|
+
main_window.ui.remove_button_state.setEnabled(True)
|
|
398
|
+
|
|
399
|
+
|
|
400
|
+
def handle_delete_button_state_with_confirmation() -> None:
|
|
401
|
+
confirm = QMessageBox(main_window)
|
|
402
|
+
confirm.setWindowTitle("Delete Button State")
|
|
403
|
+
confirm.setText("Are you sure you want to delete this button state?")
|
|
404
|
+
confirm.setStandardButtons(QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No)
|
|
405
|
+
confirm.setIcon(QMessageBox.Icon.Question)
|
|
406
|
+
button = confirm.exec()
|
|
407
|
+
if button == QMessageBox.StandardButton.Yes:
|
|
408
|
+
handle_delete_button_state()
|
|
409
|
+
|
|
410
|
+
|
|
411
|
+
def handle_delete_button_state() -> None:
|
|
412
|
+
deck_id = _deck()
|
|
413
|
+
page_id = _page()
|
|
414
|
+
button_id = _button()
|
|
415
|
+
button_state_id = _button_state()
|
|
416
|
+
if deck_id is None or page_id is None or button_id is None or button_state_id is None:
|
|
417
|
+
return
|
|
418
|
+
|
|
419
|
+
api.remove_button_state(deck_id, page_id, button_id, button_state_id)
|
|
420
|
+
main_window.ui.button_states.removeTab(main_window.ui.button_states.currentIndex())
|
|
421
|
+
if main_window.ui.button_states.count() == 1:
|
|
422
|
+
main_window.ui.remove_button_state.setEnabled(False)
|
|
423
|
+
|
|
424
|
+
|
|
425
|
+
def _closest_page(page: int, pages: List[int]) -> int:
|
|
426
|
+
if page not in pages:
|
|
427
|
+
return -1
|
|
428
|
+
page_index = pages.index(page)
|
|
429
|
+
if page_index == 0:
|
|
430
|
+
return pages[1]
|
|
431
|
+
elif page_index == len(pages) - 1:
|
|
432
|
+
return pages[page_index - 1]
|
|
433
|
+
else:
|
|
434
|
+
prev_page = pages[page_index - 1]
|
|
435
|
+
next_page = pages[page_index + 1]
|
|
436
|
+
if abs(page - prev_page) <= abs(page - next_page):
|
|
437
|
+
return prev_page
|
|
438
|
+
else:
|
|
439
|
+
return next_page
|
|
440
|
+
|
|
441
|
+
|
|
442
|
+
def redraw_buttons() -> None:
|
|
443
|
+
deck_id = _deck()
|
|
444
|
+
page_id = _page()
|
|
445
|
+
if deck_id is None or page_id is None:
|
|
446
|
+
return
|
|
447
|
+
current_tab = main_window.ui.pages.currentWidget()
|
|
448
|
+
buttons = current_tab.findChildren(QToolButton)
|
|
449
|
+
for button in buttons:
|
|
450
|
+
if not button.isHidden():
|
|
451
|
+
# When rebuilding the buttons, we hide the old ones
|
|
452
|
+
# and mark for deletion. They still hang around so
|
|
453
|
+
# ignore them here
|
|
454
|
+
icon = api.get_button_icon_pixmap(deck_id, page_id, button.property("index"))
|
|
455
|
+
if icon is not None:
|
|
456
|
+
button.setIcon(icon)
|
|
457
|
+
|
|
458
|
+
|
|
459
|
+
def redraw_button(button_index: int) -> None:
|
|
460
|
+
deck_id = _deck()
|
|
461
|
+
page_id = _page()
|
|
462
|
+
if deck_id is None or page_id is None:
|
|
463
|
+
return
|
|
464
|
+
|
|
465
|
+
current_tab = main_window.ui.pages.currentWidget()
|
|
466
|
+
buttons = current_tab.findChildren(QToolButton)
|
|
467
|
+
for button in buttons:
|
|
468
|
+
if not button.isHidden():
|
|
469
|
+
if button.property("index") == button_index:
|
|
470
|
+
icon = api.get_button_icon_pixmap(deck_id, page_id, button.property("index"))
|
|
471
|
+
if icon is not None:
|
|
472
|
+
button.setIcon(icon)
|
|
473
|
+
|
|
474
|
+
|
|
475
|
+
def set_brightness(value: int) -> None:
|
|
476
|
+
deck_id = _deck()
|
|
477
|
+
if deck_id is None:
|
|
478
|
+
return
|
|
479
|
+
api.set_brightness(deck_id, value)
|
|
480
|
+
|
|
481
|
+
|
|
482
|
+
def set_brightness_dimmed(value: int) -> None:
|
|
483
|
+
deck_id = _deck()
|
|
484
|
+
if deck_id is None:
|
|
485
|
+
return
|
|
486
|
+
api.set_brightness_dimmed(deck_id, value)
|
|
487
|
+
api.reset_dimmer(deck_id)
|
|
488
|
+
|
|
489
|
+
|
|
490
|
+
def button_clicked(clicked_button, buttons) -> None:
|
|
491
|
+
"""This method build the button states tabs user interface.
|
|
492
|
+
It is called when a button is clicked on the main page."""
|
|
493
|
+
global selected_button
|
|
494
|
+
selected_button = clicked_button
|
|
495
|
+
|
|
496
|
+
# uncheck all other buttons
|
|
497
|
+
for button in buttons:
|
|
498
|
+
if button == clicked_button:
|
|
499
|
+
continue
|
|
500
|
+
button.setChecked(False)
|
|
501
|
+
# if no button is selected, do nothing
|
|
502
|
+
if selected_button is None:
|
|
503
|
+
return
|
|
504
|
+
if not selected_button.isChecked():
|
|
505
|
+
selected_button = None
|
|
506
|
+
return
|
|
507
|
+
|
|
508
|
+
deck_id = _deck()
|
|
509
|
+
if deck_id is not None:
|
|
510
|
+
api.reset_dimmer(deck_id)
|
|
511
|
+
build_button_state_pages()
|
|
512
|
+
|
|
513
|
+
|
|
514
|
+
def build_button_state_pages():
|
|
515
|
+
ui = main_window.ui
|
|
516
|
+
blocker = QSignalBlocker(ui.button_states)
|
|
517
|
+
deck_id = _deck()
|
|
518
|
+
page_id = _page()
|
|
519
|
+
button_id = _button()
|
|
520
|
+
active_tab_index = 0
|
|
521
|
+
|
|
522
|
+
try:
|
|
523
|
+
if ui.button_states.count() > 0:
|
|
524
|
+
ui.button_states.clear()
|
|
525
|
+
|
|
526
|
+
if button_id is not None and deck_id is not None and page_id is not None:
|
|
527
|
+
current_state = api.get_button_state(deck_id, page_id, button_id)
|
|
528
|
+
|
|
529
|
+
for button_state_id in api.get_button_states(deck_id, page_id, button_id):
|
|
530
|
+
page = QWidget()
|
|
531
|
+
page.setLayout(QVBoxLayout())
|
|
532
|
+
page.setProperty("deck_id", deck_id)
|
|
533
|
+
page.setProperty("page_id", page_id)
|
|
534
|
+
page.setProperty("button_id", button_id)
|
|
535
|
+
page.setProperty("button_state_id", button_state_id)
|
|
536
|
+
label = _build_tab_label("State", button_state_id)
|
|
537
|
+
tab_index = ui.button_states.addTab(page, label)
|
|
538
|
+
page_tab = ui.button_states.widget(tab_index)
|
|
539
|
+
build_button_state_form(page_tab)
|
|
540
|
+
if button_state_id == current_state:
|
|
541
|
+
active_tab_index = tab_index
|
|
542
|
+
else:
|
|
543
|
+
# add text "No button selected"
|
|
544
|
+
page = QWidget()
|
|
545
|
+
page.setLayout(QVBoxLayout())
|
|
546
|
+
page.setProperty("deck_id", deck_id)
|
|
547
|
+
page.setProperty("page_id", page_id)
|
|
548
|
+
page.setProperty("button_id", button_id)
|
|
549
|
+
page.setProperty("button_state_id", None)
|
|
550
|
+
label = _build_tab_label("State", 0)
|
|
551
|
+
tab_index = ui.button_states.addTab(page, label)
|
|
552
|
+
page_tab = ui.button_states.widget(tab_index)
|
|
553
|
+
build_button_state_form(page_tab)
|
|
554
|
+
|
|
555
|
+
some_state = button_id is not None and ui.button_states.count() > 0
|
|
556
|
+
more_than_one_state = button_id is not None and ui.button_states.count() > 1
|
|
557
|
+
|
|
558
|
+
ui.remove_button_state.setEnabled(more_than_one_state)
|
|
559
|
+
|
|
560
|
+
if some_state:
|
|
561
|
+
ui.button_states.setCurrentIndex(active_tab_index)
|
|
562
|
+
ui.add_button_state.setEnabled(True)
|
|
563
|
+
redraw_button(button_id)
|
|
564
|
+
else:
|
|
565
|
+
ui.add_button_state.setEnabled(False)
|
|
566
|
+
finally:
|
|
567
|
+
blocker.unblock()
|
|
568
|
+
|
|
569
|
+
|
|
570
|
+
def build_button_state_form(tab) -> None:
|
|
571
|
+
global selected_button, main_window # noqa: F824
|
|
572
|
+
if hasattr(tab, "button_form"):
|
|
573
|
+
for widget in tab.findChildren(QWidget):
|
|
574
|
+
widget.hide()
|
|
575
|
+
widget.deleteLater()
|
|
576
|
+
|
|
577
|
+
tab.button_form.hide()
|
|
578
|
+
tab.button_form.deleteLater()
|
|
579
|
+
del tab.children()[0]
|
|
580
|
+
del tab.button_form
|
|
581
|
+
|
|
582
|
+
base_widget = QWidget(tab)
|
|
583
|
+
tab.children()[0].addWidget(base_widget)
|
|
584
|
+
|
|
585
|
+
tab.button_form = base_widget
|
|
586
|
+
|
|
587
|
+
tab_ui = Ui_ButtonForm()
|
|
588
|
+
tab_ui.setupUi(base_widget)
|
|
589
|
+
|
|
590
|
+
deck_id = _deck()
|
|
591
|
+
page_id = _page()
|
|
592
|
+
button_id = _button()
|
|
593
|
+
button_state_id = tab.property("button_state_id")
|
|
594
|
+
|
|
595
|
+
# set values
|
|
596
|
+
# reset the button configuration to the default
|
|
597
|
+
_reset_build_button_state_form(tab_ui)
|
|
598
|
+
|
|
599
|
+
if deck_id is None or page_id is None or button_id is None or button_state_id is None:
|
|
600
|
+
enable_button_configuration(tab_ui, False)
|
|
601
|
+
return
|
|
602
|
+
|
|
603
|
+
enable_button_configuration(tab_ui, True)
|
|
604
|
+
button_state = api.get_button_state_object(deck_id, page_id, button_id, button_state_id)
|
|
605
|
+
|
|
606
|
+
tab_ui.text.setText(button_state.text)
|
|
607
|
+
tab_ui.command.setText(button_state.command)
|
|
608
|
+
tab_ui.keys.setText(button_state.keys)
|
|
609
|
+
tab_ui.write.setPlainText(button_state.write)
|
|
610
|
+
tab_ui.change_brightness.setValue(button_state.brightness_change)
|
|
611
|
+
tab_ui.text_font_size.setValue(button_state.font_size or DEFAULT_FONT_SIZE)
|
|
612
|
+
tab_ui.text_color.setPalette(QPalette(button_state.font_color or DEFAULT_FONT_COLOR))
|
|
613
|
+
tab_ui.background_color.setPalette(QPalette(button_state.background_color or DEFAULT_BACKGROUND_COLOR))
|
|
614
|
+
tab_ui.change_brightness.setValue(button_state.brightness_change)
|
|
615
|
+
tab_ui.switch_page.setValue(button_state.switch_page)
|
|
616
|
+
tab_ui.switch_state.setValue(button_state.switch_state)
|
|
617
|
+
tab_ui.force_refresh.setChecked(button_state.force_refresh)
|
|
618
|
+
|
|
619
|
+
font_family, font_style = find_font_info(button_state.font or DEFAULT_FONT_FALLBACK_PATH)
|
|
620
|
+
prepare_button_state_form_text_font_list(tab_ui, font_family)
|
|
621
|
+
prepare_button_state_form_text_font_style_list(tab_ui, font_family, font_style)
|
|
622
|
+
|
|
623
|
+
# completer for keys
|
|
624
|
+
keys_autocomplete = KeyPressAutoComplete()
|
|
625
|
+
tab_ui.keys.setCompleter(keys_autocomplete)
|
|
626
|
+
tab_ui.keys.textChanged.connect(keys_autocomplete.update_prefix)
|
|
627
|
+
|
|
628
|
+
# connect signals
|
|
629
|
+
tab_ui.text.textChanged.connect(partial(debounced_update_button_text, tab_ui))
|
|
630
|
+
tab_ui.command.textChanged.connect(partial(debounced_update_button_attribute, "command"))
|
|
631
|
+
tab_ui.keys.textChanged.connect(partial(debounced_update_button_attribute, "keys"))
|
|
632
|
+
tab_ui.write.textChanged.connect(lambda: debounced_update_button_attribute("write", tab_ui.write.toPlainText()))
|
|
633
|
+
tab_ui.change_brightness.valueChanged.connect(partial(update_button_attribute, "change_brightness"))
|
|
634
|
+
tab_ui.text_font_size.valueChanged.connect(partial(update_displayed_button_attribute, "font_size"))
|
|
635
|
+
tab_ui.text_font.currentTextChanged.connect(lambda: update_button_attribute_font(tab_ui, "family"))
|
|
636
|
+
tab_ui.text_font_style.currentTextChanged.connect(lambda: update_button_attribute_font(tab_ui, "style"))
|
|
637
|
+
tab_ui.text_color.clicked.connect(partial(show_button_state_font_color_dialog, tab_ui))
|
|
638
|
+
tab_ui.background_color.clicked.connect(partial(show_button_state_background_color_dialog, tab_ui))
|
|
639
|
+
tab_ui.switch_page.valueChanged.connect(partial(update_button_attribute, "switch_page"))
|
|
640
|
+
tab_ui.switch_state.valueChanged.connect(partial(update_button_attribute, "switch_state"))
|
|
641
|
+
tab_ui.force_refresh.stateChanged.connect(lambda state: update_button_attribute("force_refresh", bool(state)))
|
|
642
|
+
tab_ui.add_image.clicked.connect(partial(show_button_state_image_dialog))
|
|
643
|
+
tab_ui.remove_image.clicked.connect(show_button_state_remove_image_dialog)
|
|
644
|
+
tab_ui.text_h_align.clicked.connect(partial(update_align_text_horizontal))
|
|
645
|
+
tab_ui.text_v_align.clicked.connect(partial(update_align_text_vertical))
|
|
646
|
+
|
|
647
|
+
|
|
648
|
+
def enable_button_configuration(ui: Ui_ButtonForm, enabled: bool):
|
|
649
|
+
ui.text.setEnabled(enabled)
|
|
650
|
+
ui.command.setEnabled(enabled)
|
|
651
|
+
ui.keys.setEnabled(enabled)
|
|
652
|
+
ui.text_font.setEnabled(enabled)
|
|
653
|
+
ui.text_font_style.setEnabled(enabled)
|
|
654
|
+
ui.text_font_size.setEnabled(enabled)
|
|
655
|
+
ui.write.setEnabled(enabled)
|
|
656
|
+
ui.change_brightness.setEnabled(enabled)
|
|
657
|
+
ui.switch_page.setEnabled(enabled)
|
|
658
|
+
ui.force_refresh.setEnabled(enabled)
|
|
659
|
+
ui.switch_state.setEnabled(enabled)
|
|
660
|
+
ui.add_image.setEnabled(enabled)
|
|
661
|
+
ui.remove_image.setEnabled(enabled)
|
|
662
|
+
ui.text_h_align.setEnabled(enabled)
|
|
663
|
+
ui.text_v_align.setEnabled(enabled)
|
|
664
|
+
ui.text_color.setEnabled(enabled)
|
|
665
|
+
ui.background_color.setEnabled(enabled)
|
|
666
|
+
# default black color looks like it's enabled even when it's not
|
|
667
|
+
# we set it to white when disabled to make it more obvious
|
|
668
|
+
if enabled:
|
|
669
|
+
ui.background_color.setPalette(QPalette(DEFAULT_BACKGROUND_COLOR))
|
|
670
|
+
else:
|
|
671
|
+
ui.background_color.setPalette(QPalette(DEFAULT_FONT_COLOR))
|
|
672
|
+
|
|
673
|
+
|
|
674
|
+
def prepare_button_state_form_text_font_list(ui: Ui_ButtonForm, current_font_family: str) -> None:
|
|
675
|
+
"""Prepares the font selection combo box with all available fonts"""
|
|
676
|
+
blocker = QSignalBlocker(ui.text_font)
|
|
677
|
+
try:
|
|
678
|
+
ui.text_font.clear()
|
|
679
|
+
ui.text_font.clearEditText()
|
|
680
|
+
for i, font_family in enumerate(FONTS_DICT):
|
|
681
|
+
ui.text_font.addItem(font_family)
|
|
682
|
+
font = QFont(font_family)
|
|
683
|
+
ui.text_font.setItemData(i, font)
|
|
684
|
+
ui.text_font.setItemData(i, font, Qt.FontRole) # type: ignore [attr-defined]
|
|
685
|
+
ui.text_font.setCurrentText(current_font_family)
|
|
686
|
+
finally:
|
|
687
|
+
blocker.unblock()
|
|
688
|
+
|
|
689
|
+
|
|
690
|
+
def prepare_button_state_form_text_font_style_list(
|
|
691
|
+
ui: Ui_ButtonForm, current_font_family: str, current_font_style: str
|
|
692
|
+
) -> None:
|
|
693
|
+
"""Prepares the font style selection combo box with all available styles for the selected font"""
|
|
694
|
+
blocker = QSignalBlocker(ui.text_font_style)
|
|
695
|
+
try:
|
|
696
|
+
ui.text_font_style.clear()
|
|
697
|
+
ui.text_font_style.clearEditText()
|
|
698
|
+
for _i, font_style in enumerate(FONTS_DICT[current_font_family]):
|
|
699
|
+
ui.text_font_style.addItem(font_style)
|
|
700
|
+
if current_font_style:
|
|
701
|
+
ui.text_font_style.setCurrentText(current_font_style)
|
|
702
|
+
finally:
|
|
703
|
+
blocker.unblock()
|
|
704
|
+
|
|
705
|
+
|
|
706
|
+
def show_button_state_font_color_dialog(ui: Ui_ButtonForm) -> None:
|
|
707
|
+
current_color = ui.text_color.palette().color(QPalette.ColorRole.Button)
|
|
708
|
+
color = QColorDialog.getColor(current_color, ui.text_color, "Select text color")
|
|
709
|
+
|
|
710
|
+
if color.isValid():
|
|
711
|
+
ui.text_color.setPalette(QPalette(color))
|
|
712
|
+
color_hex = color.name()
|
|
713
|
+
update_displayed_button_attribute("font_color", color_hex)
|
|
714
|
+
|
|
715
|
+
|
|
716
|
+
def show_button_state_background_color_dialog(ui: Ui_ButtonForm) -> None:
|
|
717
|
+
current_color = ui.background_color.palette().color(QPalette.ColorRole.Button)
|
|
718
|
+
color = QColorDialog.getColor(current_color, ui.background_color, "Select background color")
|
|
719
|
+
|
|
720
|
+
if color.isValid():
|
|
721
|
+
ui.background_color.setPalette(QPalette(color))
|
|
722
|
+
color_hex = color.name()
|
|
723
|
+
update_displayed_button_attribute("background_color", color_hex)
|
|
724
|
+
|
|
725
|
+
|
|
726
|
+
def show_button_state_image_dialog() -> None:
|
|
727
|
+
global last_image_dir
|
|
728
|
+
deck_id = _deck()
|
|
729
|
+
page_id = _page()
|
|
730
|
+
button_id = _button()
|
|
731
|
+
|
|
732
|
+
if deck_id is None or page_id is None or button_id is None:
|
|
733
|
+
return
|
|
734
|
+
|
|
735
|
+
image_file = api.get_button_icon(deck_id, page_id, button_id)
|
|
736
|
+
|
|
737
|
+
if not image_file:
|
|
738
|
+
if not last_image_dir:
|
|
739
|
+
image_file = os.path.expanduser("~")
|
|
740
|
+
else:
|
|
741
|
+
image_file = last_image_dir
|
|
742
|
+
|
|
743
|
+
file_name = QFileDialog.getOpenFileName(
|
|
744
|
+
main_window, "Open Image", image_file, "Image Files (*.png *.jpg *.bmp *.svg *.gif)"
|
|
745
|
+
)[0]
|
|
746
|
+
|
|
747
|
+
if file_name:
|
|
748
|
+
if file_name == image_file:
|
|
749
|
+
# if the user selects the same file name, clear out the last image
|
|
750
|
+
# this will allow the image to update in the case where the user edited the image
|
|
751
|
+
# and saved over the original file
|
|
752
|
+
update_displayed_button_attribute("icon", "")
|
|
753
|
+
last_image_dir = os.path.dirname(file_name)
|
|
754
|
+
update_displayed_button_attribute("icon", file_name)
|
|
755
|
+
|
|
756
|
+
|
|
757
|
+
def show_button_state_remove_image_dialog() -> None:
|
|
758
|
+
deck_id = _deck()
|
|
759
|
+
page_id = _page()
|
|
760
|
+
button_id = _button()
|
|
761
|
+
|
|
762
|
+
if deck_id is None or page_id is None or button_id is None:
|
|
763
|
+
return
|
|
764
|
+
|
|
765
|
+
image = api.get_button_icon(deck_id, page_id, button_id)
|
|
766
|
+
if image:
|
|
767
|
+
confirm = QMessageBox(main_window)
|
|
768
|
+
confirm.setWindowTitle("Remove image")
|
|
769
|
+
confirm.setText("Are you sure you want to remove the image for this button?")
|
|
770
|
+
confirm.setStandardButtons(QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No)
|
|
771
|
+
confirm.setIcon(QMessageBox.Icon.Question)
|
|
772
|
+
button = confirm.exec()
|
|
773
|
+
if button == QMessageBox.StandardButton.Yes:
|
|
774
|
+
update_displayed_button_attribute("icon", "")
|
|
775
|
+
|
|
776
|
+
|
|
777
|
+
def update_align_text_vertical() -> None:
|
|
778
|
+
deck_id = _deck()
|
|
779
|
+
page_id = _page()
|
|
780
|
+
button_id = _button()
|
|
781
|
+
align_changes = {
|
|
782
|
+
"": "middle-bottom",
|
|
783
|
+
"bottom": "middle-bottom",
|
|
784
|
+
"middle-bottom": "middle",
|
|
785
|
+
"middle": "middle-top",
|
|
786
|
+
"middle-top": "top",
|
|
787
|
+
}
|
|
788
|
+
if deck_id is not None and page_id is not None and button_id is not None:
|
|
789
|
+
current_position = api.get_button_text_vertical_align(deck_id, page_id, button_id)
|
|
790
|
+
next_position = align_changes.get(current_position, "")
|
|
791
|
+
update_displayed_button_attribute("text_vertical_align", next_position)
|
|
792
|
+
|
|
793
|
+
|
|
794
|
+
def update_align_text_horizontal() -> None:
|
|
795
|
+
deck_id = _deck()
|
|
796
|
+
page_id = _page()
|
|
797
|
+
button_id = _button()
|
|
798
|
+
align_changes = {
|
|
799
|
+
"": "left",
|
|
800
|
+
"left": "right",
|
|
801
|
+
"center": "left",
|
|
802
|
+
}
|
|
803
|
+
if deck_id is not None and page_id is not None and button_id is not None:
|
|
804
|
+
current_position = api.get_button_text_horizontal_align(deck_id, page_id, button_id)
|
|
805
|
+
next_position = align_changes.get(current_position, "")
|
|
806
|
+
update_displayed_button_attribute("text_horizontal_align", next_position)
|
|
807
|
+
|
|
808
|
+
|
|
809
|
+
@debounce(timeout=500)
|
|
810
|
+
def debounced_update_button_text(ui: Ui_ButtonForm) -> None:
|
|
811
|
+
"""Instead of directly updating the text (label) associated with
|
|
812
|
+
the button, add a small delay. If this is called before the
|
|
813
|
+
timer fires, delay it again. Effectively this creates an update
|
|
814
|
+
queue. It makes the textbox more response, as rendering the button
|
|
815
|
+
and saving to the API each time can feel somewhat slow.
|
|
816
|
+
"""
|
|
817
|
+
text = ui.text.toPlainText()
|
|
818
|
+
update_displayed_button_attribute("text", text)
|
|
819
|
+
|
|
820
|
+
|
|
821
|
+
@debounce(timeout=500)
|
|
822
|
+
def debounced_update_button_attribute(attribute: str, value: str) -> None:
|
|
823
|
+
"""Instead of directly updating the attribute associated with
|
|
824
|
+
the button, add a small delay. If this is called before the
|
|
825
|
+
timer fires, delay it again. Effectively this creates an update
|
|
826
|
+
queue. It makes the textbox more response, as rendering the button
|
|
827
|
+
and saving to the API each time can feel somewhat slow.
|
|
828
|
+
"""
|
|
829
|
+
update_button_attribute(attribute, value)
|
|
830
|
+
|
|
831
|
+
|
|
832
|
+
def update_button_attribute_font(ui: Ui_ButtonForm, kind: str) -> None:
|
|
833
|
+
"""Update the font associated with the button"""
|
|
834
|
+
font_family = ui.text_font.currentText()
|
|
835
|
+
font_style = ui.text_font_style.currentText()
|
|
836
|
+
# when the font family changes, update the font style list
|
|
837
|
+
if kind == "family":
|
|
838
|
+
prepare_button_state_form_text_font_style_list(ui, font_family, "")
|
|
839
|
+
font_style = list(FONTS_DICT[font_family])[0]
|
|
840
|
+
|
|
841
|
+
font = FONTS_DICT[font_family][font_style]
|
|
842
|
+
|
|
843
|
+
# if the font is not valid, we roll back the change to current value
|
|
844
|
+
# in case rollback fails, we set the default font
|
|
845
|
+
if is_a_valid_text_filter_font(font):
|
|
846
|
+
update_displayed_button_attribute("font", font)
|
|
847
|
+
else:
|
|
848
|
+
deck_id = _deck()
|
|
849
|
+
page_id = _page()
|
|
850
|
+
button_id = _button()
|
|
851
|
+
if deck_id is not None and page_id is not None and button_id is not None:
|
|
852
|
+
current_font = api.get_button_font(deck_id, page_id, button_id)
|
|
853
|
+
font_family, _ = find_font_info(current_font)
|
|
854
|
+
ui.text_font.setCurrentText(font_family)
|
|
855
|
+
else:
|
|
856
|
+
ui.text_font.setCurrentText(DEFAULT_FONT_FAMILY)
|
|
857
|
+
|
|
858
|
+
|
|
859
|
+
def _reset_build_button_state_form(ui: Ui_ButtonForm):
|
|
860
|
+
"""Clears the configuration for a button and disables editing of them."""
|
|
861
|
+
ui.text.clear()
|
|
862
|
+
ui.command.clear()
|
|
863
|
+
ui.keys.clear()
|
|
864
|
+
ui.text_font.clearEditText()
|
|
865
|
+
ui.text_font_size.setValue(0)
|
|
866
|
+
ui.text_color.setPalette(QPalette(DEFAULT_FONT_COLOR))
|
|
867
|
+
ui.background_color.setPalette(QPalette(DEFAULT_BACKGROUND_COLOR))
|
|
868
|
+
ui.write.clear()
|
|
869
|
+
ui.change_brightness.setValue(0)
|
|
870
|
+
ui.switch_page.setValue(0)
|
|
871
|
+
ui.force_refresh.setChecked(False)
|
|
872
|
+
ui.switch_state.setValue(0)
|
|
873
|
+
|
|
874
|
+
|
|
875
|
+
def browse_documentation():
|
|
876
|
+
url = QUrl("https://millaguie.github.io/streamdeck-gui-ng/")
|
|
877
|
+
QDesktopServices.openUrl(url)
|
|
878
|
+
|
|
879
|
+
|
|
880
|
+
def browse_github():
|
|
881
|
+
url = QUrl("https://github.com/millaguie/streamdeck-gui-ng")
|
|
882
|
+
QDesktopServices.openUrl(url)
|
|
883
|
+
|
|
884
|
+
|
|
885
|
+
def build_buttons(ui, tab) -> None:
|
|
886
|
+
global selected_button
|
|
887
|
+
|
|
888
|
+
if hasattr(tab, "deck_buttons"):
|
|
889
|
+
buttons = tab.findChildren(QToolButton)
|
|
890
|
+
for button in buttons:
|
|
891
|
+
button.hide()
|
|
892
|
+
# Mark them as hidden. They will be GC'd later
|
|
893
|
+
button.deleteLater()
|
|
894
|
+
|
|
895
|
+
tab.deck_buttons.hide()
|
|
896
|
+
tab.deck_buttons.deleteLater()
|
|
897
|
+
# Remove the inner page
|
|
898
|
+
del tab.children()[0]
|
|
899
|
+
# Remove the property
|
|
900
|
+
del tab.deck_buttons
|
|
901
|
+
|
|
902
|
+
selected_button = None
|
|
903
|
+
# When rebuilding any selection is cleared
|
|
904
|
+
|
|
905
|
+
deck_id = _deck()
|
|
906
|
+
|
|
907
|
+
if not deck_id:
|
|
908
|
+
return
|
|
909
|
+
deck_rows, deck_columns = api.get_deck_layout(deck_id)
|
|
910
|
+
|
|
911
|
+
# Create a new base_widget with tab as it's parent
|
|
912
|
+
# This is effectively a "blank tab"
|
|
913
|
+
base_widget = QWidget(tab)
|
|
914
|
+
|
|
915
|
+
# Add an inner page (QtWidget) to the page
|
|
916
|
+
tab.children()[0].addWidget(base_widget)
|
|
917
|
+
|
|
918
|
+
# Set a property - this allows us to check later
|
|
919
|
+
# if we've already created the buttons
|
|
920
|
+
tab.deck_buttons = base_widget
|
|
921
|
+
|
|
922
|
+
row_layout = QVBoxLayout(base_widget)
|
|
923
|
+
index = 0
|
|
924
|
+
buttons = []
|
|
925
|
+
for _row in range(deck_rows):
|
|
926
|
+
column_layout = QHBoxLayout()
|
|
927
|
+
row_layout.addLayout(column_layout)
|
|
928
|
+
|
|
929
|
+
for _column in range(deck_columns):
|
|
930
|
+
button = DraggableButton(base_widget, ui, api)
|
|
931
|
+
button.setCheckable(True)
|
|
932
|
+
button.setProperty("index", index)
|
|
933
|
+
button.setSizePolicy(QSizePolicy.Policy.MinimumExpanding, QSizePolicy.Policy.MinimumExpanding)
|
|
934
|
+
button.setToolButtonStyle(Qt.ToolButtonStyle.ToolButtonIconOnly)
|
|
935
|
+
button.setIconSize(QSize(80, 80))
|
|
936
|
+
button.setStyleSheet(BUTTON_STYLE)
|
|
937
|
+
buttons.append(button)
|
|
938
|
+
column_layout.addWidget(button)
|
|
939
|
+
index += 1
|
|
940
|
+
|
|
941
|
+
column_layout.addStretch(1)
|
|
942
|
+
row_layout.addStretch(1)
|
|
943
|
+
|
|
944
|
+
# Note that the button click event captures the ui variable, the current button
|
|
945
|
+
# and all the other buttons
|
|
946
|
+
for button in buttons:
|
|
947
|
+
button.clicked.connect(
|
|
948
|
+
lambda checked=False, current_button=button, all_buttons=buttons: button_clicked(
|
|
949
|
+
current_button, all_buttons
|
|
950
|
+
)
|
|
951
|
+
)
|
|
952
|
+
|
|
953
|
+
|
|
954
|
+
def export_config(window, api) -> None:
|
|
955
|
+
file_name = QFileDialog.getSaveFileName(
|
|
956
|
+
window, "Export Config", os.path.expanduser("~/streamdeck_ui_export.json"), "JSON (*.json)"
|
|
957
|
+
)[0]
|
|
958
|
+
if not file_name:
|
|
959
|
+
return
|
|
960
|
+
|
|
961
|
+
api.export_config(file_name)
|
|
962
|
+
|
|
963
|
+
|
|
964
|
+
def import_config(window, api) -> None:
|
|
965
|
+
file_name = QFileDialog.getOpenFileName(window, "Import Config", os.path.expanduser("~"), "Config Files (*.json)")[
|
|
966
|
+
0
|
|
967
|
+
]
|
|
968
|
+
if not file_name:
|
|
969
|
+
return
|
|
970
|
+
|
|
971
|
+
api.import_config(file_name)
|
|
972
|
+
redraw_buttons()
|
|
973
|
+
|
|
974
|
+
|
|
975
|
+
def _build_tab_label(prefix: str, page_id: int) -> str:
|
|
976
|
+
return f"{prefix} {page_id + 1}" if page_id == 0 else f"{page_id + 1}"
|
|
977
|
+
|
|
978
|
+
|
|
979
|
+
def build_device(ui, _device_index=None) -> None:
|
|
980
|
+
"""This method builds the device configuration user interface.
|
|
981
|
+
It is called if you switch to a different Stream Deck,
|
|
982
|
+
a Stream Deck is added or when the last one is removed.
|
|
983
|
+
It must deal with the case where there is no Stream Deck as
|
|
984
|
+
a result.
|
|
985
|
+
"""
|
|
986
|
+
blocker = QSignalBlocker(ui.pages)
|
|
987
|
+
try:
|
|
988
|
+
deck_id = _deck()
|
|
989
|
+
style = DEVICE_PAGE_STYLE if ui.device_list.count() > 0 else ""
|
|
990
|
+
|
|
991
|
+
# the device was removed while we were building the ui, then we skip
|
|
992
|
+
if deck_id is None:
|
|
993
|
+
return
|
|
994
|
+
|
|
995
|
+
# clear the pages
|
|
996
|
+
if ui.pages.count() > 0:
|
|
997
|
+
ui.pages.clear()
|
|
998
|
+
|
|
999
|
+
current_page = api.get_page(deck_id)
|
|
1000
|
+
active_tab_index = 0
|
|
1001
|
+
|
|
1002
|
+
# Add the pages
|
|
1003
|
+
for page_id in api.get_pages(deck_id):
|
|
1004
|
+
page = QWidget()
|
|
1005
|
+
page.setLayout(QGridLayout())
|
|
1006
|
+
page.setProperty("deck_id", deck_id)
|
|
1007
|
+
page.setProperty("page_id", page_id)
|
|
1008
|
+
page.setStyleSheet(style)
|
|
1009
|
+
label = _build_tab_label("Page", page_id)
|
|
1010
|
+
tab_index = ui.pages.addTab(page, label)
|
|
1011
|
+
page_tab = ui.pages.widget(tab_index)
|
|
1012
|
+
build_buttons(ui, page_tab)
|
|
1013
|
+
if page_id == current_page:
|
|
1014
|
+
active_tab_index = tab_index
|
|
1015
|
+
|
|
1016
|
+
if ui.pages.count() > 1:
|
|
1017
|
+
ui.remove_page.setEnabled(True)
|
|
1018
|
+
else:
|
|
1019
|
+
ui.remove_page.setEnabled(False)
|
|
1020
|
+
|
|
1021
|
+
if ui.device_list.count() > 0:
|
|
1022
|
+
ui.settingsButton.setEnabled(True)
|
|
1023
|
+
ui.add_page.setEnabled(True)
|
|
1024
|
+
# Set the active page for this device
|
|
1025
|
+
ui.pages.setCurrentIndex(active_tab_index)
|
|
1026
|
+
|
|
1027
|
+
# Draw the buttons for the active page
|
|
1028
|
+
redraw_buttons()
|
|
1029
|
+
else:
|
|
1030
|
+
ui.settingsButton.setEnabled(False)
|
|
1031
|
+
ui.add_page.setEnabled(False)
|
|
1032
|
+
finally:
|
|
1033
|
+
blocker.unblock()
|
|
1034
|
+
|
|
1035
|
+
|
|
1036
|
+
class MainWindow(QMainWindow):
|
|
1037
|
+
"""Represents the main streamdeck-ui configuration Window. A QMainWindow
|
|
1038
|
+
object provides a lot of standard main window features out the box.
|
|
1039
|
+
|
|
1040
|
+
The QtCreator UI designer allows you to create a UI quickly. It compiles
|
|
1041
|
+
into a class called Ui_MainWindow() and everything comes together by
|
|
1042
|
+
calling the setupUi() method and passing a reference to the QMainWindow.
|
|
1043
|
+
"""
|
|
1044
|
+
|
|
1045
|
+
ui: Ui_MainWindow
|
|
1046
|
+
"A reference to all the UI objects for the main window"
|
|
1047
|
+
|
|
1048
|
+
tray: QSystemTrayIcon
|
|
1049
|
+
"A reference to the system tray icon"
|
|
1050
|
+
|
|
1051
|
+
window_shown: bool
|
|
1052
|
+
settings: QSettings
|
|
1053
|
+
|
|
1054
|
+
def __init__(self):
|
|
1055
|
+
super(MainWindow, self).__init__()
|
|
1056
|
+
self.ui = Ui_MainWindow()
|
|
1057
|
+
self.ui.setupUi(self)
|
|
1058
|
+
self.window_shown = True
|
|
1059
|
+
self.settings = QSettings("streamdeck-ui", "streamdeck-ui")
|
|
1060
|
+
self.restoreGeometry(self.settings.value("geometry", self.saveGeometry()))
|
|
1061
|
+
|
|
1062
|
+
def closeEvent(self, event) -> None: # noqa: N802 - Part of QT signature.
|
|
1063
|
+
self.settings.setValue("geometry", self.saveGeometry())
|
|
1064
|
+
self.window_shown = False
|
|
1065
|
+
self.hide()
|
|
1066
|
+
event.ignore()
|
|
1067
|
+
|
|
1068
|
+
def systray_clicked(self, status=None) -> None:
|
|
1069
|
+
if status is QSystemTrayIcon.ActivationReason.Context:
|
|
1070
|
+
return
|
|
1071
|
+
if self.window_shown:
|
|
1072
|
+
self.hide()
|
|
1073
|
+
self.window_shown = False
|
|
1074
|
+
return
|
|
1075
|
+
|
|
1076
|
+
self.bring_to_top()
|
|
1077
|
+
|
|
1078
|
+
def bring_to_top(self):
|
|
1079
|
+
self.show()
|
|
1080
|
+
self.activateWindow()
|
|
1081
|
+
self.raise_()
|
|
1082
|
+
self.window_shown = True
|
|
1083
|
+
|
|
1084
|
+
def about_dialog(self):
|
|
1085
|
+
title = "About StreamDeck UI"
|
|
1086
|
+
description = "A Linux compatible UI for the Elgato Stream Deck."
|
|
1087
|
+
app = QApplication.instance()
|
|
1088
|
+
body = [description, "Version {}\n".format(app.applicationVersion())]
|
|
1089
|
+
dependencies = ("streamdeck", "pyside6", "pillow", "pynput")
|
|
1090
|
+
for dep in dependencies:
|
|
1091
|
+
try:
|
|
1092
|
+
dist_version = version(dep)
|
|
1093
|
+
body.append("{} {}".format(dep, dist_version))
|
|
1094
|
+
except PackageNotFoundError:
|
|
1095
|
+
pass
|
|
1096
|
+
QMessageBox.about(self, title, "\n".join(body))
|
|
1097
|
+
|
|
1098
|
+
|
|
1099
|
+
def update_displayed_button_attribute(attribute: str, value: Union[str, int]) -> None:
|
|
1100
|
+
"""Updates the given attribute for the currently selected button.
|
|
1101
|
+
and updates the icon of the current selected button."""
|
|
1102
|
+
updated = update_button_attribute(attribute, value)
|
|
1103
|
+
|
|
1104
|
+
if not updated:
|
|
1105
|
+
return
|
|
1106
|
+
|
|
1107
|
+
deck_id = _deck()
|
|
1108
|
+
page_id = _page()
|
|
1109
|
+
button_id = _button()
|
|
1110
|
+
|
|
1111
|
+
if deck_id is None or page_id is None or button_id is None:
|
|
1112
|
+
return
|
|
1113
|
+
|
|
1114
|
+
icon = api.get_button_icon_pixmap(deck_id, page_id, button_id)
|
|
1115
|
+
if icon is not None and selected_button is not None:
|
|
1116
|
+
selected_button.setIcon(icon)
|
|
1117
|
+
|
|
1118
|
+
|
|
1119
|
+
def update_button_attribute(attribute: str, value: Union[str, int]) -> bool:
|
|
1120
|
+
"""
|
|
1121
|
+
Updates the given attribute for the currently selected button.
|
|
1122
|
+
and updates the icon of the current selected button.
|
|
1123
|
+
"""
|
|
1124
|
+
deck_id = _deck()
|
|
1125
|
+
page_id = _page()
|
|
1126
|
+
button_id = _button()
|
|
1127
|
+
|
|
1128
|
+
if deck_id is None or page_id is None or button_id is None:
|
|
1129
|
+
return False
|
|
1130
|
+
|
|
1131
|
+
update_function = getattr(api, f"set_button_{attribute}")
|
|
1132
|
+
update_function(deck_id, page_id, button_id, value)
|
|
1133
|
+
|
|
1134
|
+
return True
|
|
1135
|
+
|
|
1136
|
+
|
|
1137
|
+
def change_brightness(deck_id: str, brightness: int):
|
|
1138
|
+
"""Changes the brightness of the given streamdeck, but does not save
|
|
1139
|
+
the state."""
|
|
1140
|
+
api.decks_by_serial[deck_id].set_brightness(brightness)
|
|
1141
|
+
|
|
1142
|
+
|
|
1143
|
+
class SettingsDialog(QDialog):
|
|
1144
|
+
ui: Ui_SettingsDialog
|
|
1145
|
+
|
|
1146
|
+
def __init__(self, parent):
|
|
1147
|
+
super().__init__(parent)
|
|
1148
|
+
self.ui = Ui_SettingsDialog()
|
|
1149
|
+
self.ui.setupUi(self)
|
|
1150
|
+
self.show()
|
|
1151
|
+
|
|
1152
|
+
|
|
1153
|
+
def show_settings(window: MainWindow) -> None:
|
|
1154
|
+
"""Shows the settings dialog and allows the user the change deck specific
|
|
1155
|
+
settings. Settings are not saved until OK is clicked."""
|
|
1156
|
+
deck_id = _deck()
|
|
1157
|
+
|
|
1158
|
+
if deck_id is None:
|
|
1159
|
+
return
|
|
1160
|
+
|
|
1161
|
+
settings = SettingsDialog(window)
|
|
1162
|
+
api.stop_dimmer(deck_id)
|
|
1163
|
+
|
|
1164
|
+
for label, value in dimmer_options.items():
|
|
1165
|
+
settings.ui.dim.addItem(f"{label}", userData=value)
|
|
1166
|
+
|
|
1167
|
+
existing_timeout = api.get_display_timeout(deck_id)
|
|
1168
|
+
existing_index = next((i for i, (k, v) in enumerate(dimmer_options.items()) if v == existing_timeout), None)
|
|
1169
|
+
|
|
1170
|
+
if existing_index is None:
|
|
1171
|
+
settings.ui.dim.addItem(f"Custom: {existing_timeout}s", userData=existing_timeout)
|
|
1172
|
+
existing_index = settings.ui.dim.count() - 1
|
|
1173
|
+
settings.ui.dim.setCurrentIndex(existing_index)
|
|
1174
|
+
else:
|
|
1175
|
+
settings.ui.dim.setCurrentIndex(existing_index)
|
|
1176
|
+
|
|
1177
|
+
existing_brightness_dimmed = api.get_brightness_dimmed(deck_id)
|
|
1178
|
+
settings.ui.brightness_dimmed.setValue(existing_brightness_dimmed)
|
|
1179
|
+
|
|
1180
|
+
settings.ui.label_streamdeck.setText(deck_id)
|
|
1181
|
+
settings.ui.brightness.setValue(api.get_brightness(deck_id))
|
|
1182
|
+
settings.ui.brightness.valueChanged.connect(partial(change_brightness, deck_id))
|
|
1183
|
+
settings.ui.dim.currentIndexChanged.connect(partial(disable_dim_settings, settings))
|
|
1184
|
+
if settings.exec():
|
|
1185
|
+
if existing_index != settings.ui.dim.currentIndex():
|
|
1186
|
+
api.set_display_timeout(deck_id, settings.ui.dim.currentData())
|
|
1187
|
+
set_brightness(settings.ui.brightness.value())
|
|
1188
|
+
set_brightness_dimmed(settings.ui.brightness_dimmed.value())
|
|
1189
|
+
else:
|
|
1190
|
+
# User cancelled, reset to original brightness
|
|
1191
|
+
change_brightness(deck_id, api.get_brightness(deck_id))
|
|
1192
|
+
|
|
1193
|
+
api.reset_dimmer(deck_id)
|
|
1194
|
+
|
|
1195
|
+
|
|
1196
|
+
def disable_dim_settings(settings: SettingsDialog, _index: int) -> None:
|
|
1197
|
+
disable = dimmer_options.get(settings.ui.dim.currentText()) == 0
|
|
1198
|
+
settings.ui.brightness_dimmed.setDisabled(disable)
|
|
1199
|
+
settings.ui.label_brightness_dimmed.setDisabled(disable)
|
|
1200
|
+
|
|
1201
|
+
|
|
1202
|
+
def toggle_dim_all() -> None:
|
|
1203
|
+
api.toggle_dimmers()
|
|
1204
|
+
|
|
1205
|
+
|
|
1206
|
+
def create_main_window(api: StreamDeckServer, app: QApplication) -> MainWindow:
|
|
1207
|
+
"""Creates the main application window and configures slots and signals"""
|
|
1208
|
+
global main_window
|
|
1209
|
+
|
|
1210
|
+
main_window = MainWindow()
|
|
1211
|
+
ui = main_window.ui
|
|
1212
|
+
|
|
1213
|
+
ui.settingsButton.clicked.connect(partial(show_settings, main_window))
|
|
1214
|
+
ui.add_page.clicked.connect(handle_new_page)
|
|
1215
|
+
ui.remove_page.clicked.connect(handle_delete_page_with_confirmation)
|
|
1216
|
+
ui.add_button_state.clicked.connect(handle_new_button_state)
|
|
1217
|
+
ui.add_button_state.setEnabled(False)
|
|
1218
|
+
ui.remove_button_state.clicked.connect(handle_delete_button_state_with_confirmation)
|
|
1219
|
+
ui.remove_button_state.setEnabled(False)
|
|
1220
|
+
ui.actionExport.triggered.connect(partial(export_config, main_window, api))
|
|
1221
|
+
ui.actionImport.triggered.connect(partial(import_config, main_window, api))
|
|
1222
|
+
ui.actionExit.triggered.connect(app.exit)
|
|
1223
|
+
ui.actionAbout.triggered.connect(main_window.about_dialog)
|
|
1224
|
+
ui.actionDocs.triggered.connect(browse_documentation)
|
|
1225
|
+
ui.actionGithub.triggered.connect(browse_github)
|
|
1226
|
+
ui.settingsButton.setEnabled(False)
|
|
1227
|
+
ui.button_states.clear()
|
|
1228
|
+
build_button_state_pages()
|
|
1229
|
+
|
|
1230
|
+
ui = main_window.ui
|
|
1231
|
+
# allow call redraw_button from ui instance
|
|
1232
|
+
ui.redraw_button = redraw_button # type: ignore [attr-defined]
|
|
1233
|
+
|
|
1234
|
+
api.streamdeck_keys.key_pressed.connect(partial(handle_keypress, ui))
|
|
1235
|
+
|
|
1236
|
+
ui.device_list.currentIndexChanged.connect(partial(build_device, ui))
|
|
1237
|
+
ui.pages.currentChanged.connect(lambda: handle_change_page())
|
|
1238
|
+
ui.button_states.currentChanged.connect(lambda: handle_change_button_state())
|
|
1239
|
+
api.plugevents.attached.connect(partial(streamdeck_attached, ui))
|
|
1240
|
+
api.plugevents.detached.connect(partial(streamdeck_detached, ui))
|
|
1241
|
+
api.plugevents.cpu_changed.connect(partial(streamdeck_cpu_changed, ui))
|
|
1242
|
+
|
|
1243
|
+
return main_window
|
|
1244
|
+
|
|
1245
|
+
|
|
1246
|
+
def show_migration_config_warning_and_check(app: QApplication) -> None:
|
|
1247
|
+
"""Shows a warning dialog when a different configuration version is detected.
|
|
1248
|
+
If the user confirms the migration, the configuration is migrated and the
|
|
1249
|
+
application continues. Otherwise, the application exits."""
|
|
1250
|
+
if not config_file_need_migration(STATE_FILE):
|
|
1251
|
+
return
|
|
1252
|
+
|
|
1253
|
+
confirm = QMessageBox(main_window)
|
|
1254
|
+
confirm.setWindowTitle("Old configuration detected")
|
|
1255
|
+
confirm.setText(
|
|
1256
|
+
"The configuration file format has changed. \n"
|
|
1257
|
+
"Do you want to upgrade your configuration to the new format?\n\n"
|
|
1258
|
+
f"If you confirm a copy of your current configuration will be created in {STATE_FILE_BACKUP}\n"
|
|
1259
|
+
"Otherwise the application will exit."
|
|
1260
|
+
)
|
|
1261
|
+
confirm.setStandardButtons(QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No)
|
|
1262
|
+
confirm.setIcon(QMessageBox.Icon.Warning)
|
|
1263
|
+
button = confirm.exec()
|
|
1264
|
+
|
|
1265
|
+
if button == QMessageBox.StandardButton.No:
|
|
1266
|
+
app.quit()
|
|
1267
|
+
sys.exit()
|
|
1268
|
+
|
|
1269
|
+
if button == QMessageBox.StandardButton.Yes:
|
|
1270
|
+
do_config_file_migration()
|
|
1271
|
+
|
|
1272
|
+
|
|
1273
|
+
def create_tray(logo: QIcon, app: QApplication) -> QSystemTrayIcon:
|
|
1274
|
+
"""Creates a system tray with the provided icon and parent. The main
|
|
1275
|
+
window passed will be activated when clicked.
|
|
1276
|
+
"""
|
|
1277
|
+
main_window.tray = QSystemTrayIcon(logo, app)
|
|
1278
|
+
main_window.tray.activated.connect(main_window.systray_clicked) # type: ignore [attr-defined]
|
|
1279
|
+
|
|
1280
|
+
menu = QMenu()
|
|
1281
|
+
action_dim = QAction("Dim display (toggle)", main_window)
|
|
1282
|
+
action_dim.triggered.connect(toggle_dim_all) # type: ignore [attr-defined]
|
|
1283
|
+
action_configure = QAction("Configure...", main_window)
|
|
1284
|
+
action_configure.triggered.connect(main_window.bring_to_top) # type: ignore [attr-defined]
|
|
1285
|
+
menu.addAction(action_dim)
|
|
1286
|
+
menu.addAction(action_configure)
|
|
1287
|
+
menu.addSeparator()
|
|
1288
|
+
action_exit = QAction("Exit", main_window)
|
|
1289
|
+
action_exit.triggered.connect(app.exit) # type: ignore [attr-defined]
|
|
1290
|
+
menu.addAction(action_exit)
|
|
1291
|
+
main_window.tray.setContextMenu(menu)
|
|
1292
|
+
return main_window.tray
|
|
1293
|
+
|
|
1294
|
+
|
|
1295
|
+
def show_tray_warning_message(message: str) -> None:
|
|
1296
|
+
"""Shows a warning message in the system tray"""
|
|
1297
|
+
main_window.tray.showMessage("Warning", message, QSystemTrayIcon.MessageIcon.Warning, 5000)
|
|
1298
|
+
|
|
1299
|
+
|
|
1300
|
+
def streamdeck_cpu_changed(ui, serial_number: str, cpu: int):
|
|
1301
|
+
if cpu > 100:
|
|
1302
|
+
cpu = 100
|
|
1303
|
+
if _deck() == serial_number:
|
|
1304
|
+
ui.cpu_usage.setValue(cpu)
|
|
1305
|
+
ui.cpu_usage.setToolTip(f"Rendering CPU usage: {cpu}%")
|
|
1306
|
+
ui.cpu_usage.update()
|
|
1307
|
+
|
|
1308
|
+
|
|
1309
|
+
def streamdeck_attached(ui, deck: Dict):
|
|
1310
|
+
serial_number = deck["serial_number"]
|
|
1311
|
+
blocker = QSignalBlocker(ui.device_list)
|
|
1312
|
+
try:
|
|
1313
|
+
ui.device_list.addItem(f"{deck['type']} - {serial_number}", userData=serial_number)
|
|
1314
|
+
finally:
|
|
1315
|
+
blocker.unblock()
|
|
1316
|
+
build_device(ui)
|
|
1317
|
+
|
|
1318
|
+
|
|
1319
|
+
def streamdeck_detached(ui, serial_number):
|
|
1320
|
+
index = ui.device_list.findData(serial_number)
|
|
1321
|
+
if index != -1:
|
|
1322
|
+
# Should not be (how can you remove a device that was never attached?)
|
|
1323
|
+
# Check anyway
|
|
1324
|
+
blocker = QSignalBlocker(ui.device_list)
|
|
1325
|
+
try:
|
|
1326
|
+
ui.device_list.removeItem(index)
|
|
1327
|
+
finally:
|
|
1328
|
+
blocker.unblock()
|
|
1329
|
+
build_device(ui)
|
|
1330
|
+
|
|
1331
|
+
|
|
1332
|
+
def configure_signals(app: QApplication, cli: CLIStreamDeckServer):
|
|
1333
|
+
"""Configures the termination signals for the application."""
|
|
1334
|
+
# Configure signal handlers
|
|
1335
|
+
# https://stackoverflow.com/a/4939113/192815
|
|
1336
|
+
timer = QTimer()
|
|
1337
|
+
timer.start(500)
|
|
1338
|
+
timer.timeout.connect(lambda: None) # type: ignore [attr-defined] # Let interpreter run to handle signal
|
|
1339
|
+
|
|
1340
|
+
# Handle SIGTERM so we release semaphore and shutdown API gracefully
|
|
1341
|
+
signal.signal(signal.SIGTERM, partial(sigterm_handler, app, cli))
|
|
1342
|
+
|
|
1343
|
+
# Handle <ctrl+c>
|
|
1344
|
+
signal.signal(signal.SIGINT, partial(sigterm_handler, app, cli))
|
|
1345
|
+
|
|
1346
|
+
|
|
1347
|
+
def sigterm_handler(app, cli, signal_value, frame):
|
|
1348
|
+
print("Received signal", signal_value, frame)
|
|
1349
|
+
api.stop()
|
|
1350
|
+
cli.stop()
|
|
1351
|
+
app.quit()
|
|
1352
|
+
if signal_value == signal.SIGTERM:
|
|
1353
|
+
# Indicate to systemd that it was a clean termination
|
|
1354
|
+
print("Exiting normally")
|
|
1355
|
+
sys.exit()
|
|
1356
|
+
else:
|
|
1357
|
+
# Terminations for other reasons are treated as an error condition
|
|
1358
|
+
sys.exit(1)
|
|
1359
|
+
|
|
1360
|
+
|
|
1361
|
+
def start(_exit: bool = False) -> None:
|
|
1362
|
+
global api, main_window # noqa: F824
|
|
1363
|
+
show_ui = True
|
|
1364
|
+
if "-h" in sys.argv or "--help" in sys.argv:
|
|
1365
|
+
print(f"Usage: {os.path.basename(sys.argv[0])}")
|
|
1366
|
+
print("Flags:")
|
|
1367
|
+
print(" -h, --help\tShow this message")
|
|
1368
|
+
print(" -n, --no-ui\tRun the program without showing a UI")
|
|
1369
|
+
return
|
|
1370
|
+
elif "-n" in sys.argv or "--no-ui" in sys.argv:
|
|
1371
|
+
show_ui = False
|
|
1372
|
+
|
|
1373
|
+
try:
|
|
1374
|
+
app_version = version("streamdeck-gui-ng")
|
|
1375
|
+
except PackageNotFoundError:
|
|
1376
|
+
app_version = "devel"
|
|
1377
|
+
|
|
1378
|
+
try:
|
|
1379
|
+
with Semaphore("/tmp/streamdeck_ui.lock"): # nosec - this file is only observed with advisory lock
|
|
1380
|
+
# The semaphore was created, so this is the first instance
|
|
1381
|
+
|
|
1382
|
+
# The QApplication object holds the Qt event loop, and you need one of these
|
|
1383
|
+
# for your application
|
|
1384
|
+
app = QApplication(sys.argv)
|
|
1385
|
+
app.setApplicationName(APP_NAME)
|
|
1386
|
+
app.setApplicationVersion(app_version)
|
|
1387
|
+
logo = QIcon(APP_LOGO)
|
|
1388
|
+
app.setWindowIcon(logo)
|
|
1389
|
+
main_window = create_main_window(api, app)
|
|
1390
|
+
create_tray(logo, app)
|
|
1391
|
+
|
|
1392
|
+
# check if we want to continue with the configuration migrate
|
|
1393
|
+
show_migration_config_warning_and_check(app)
|
|
1394
|
+
|
|
1395
|
+
# read the state file if it exists
|
|
1396
|
+
if os.path.isfile(STATE_FILE):
|
|
1397
|
+
api.open_config(STATE_FILE)
|
|
1398
|
+
api.start()
|
|
1399
|
+
|
|
1400
|
+
cli = CLIStreamDeckServer(api, main_window.ui)
|
|
1401
|
+
cli.start()
|
|
1402
|
+
|
|
1403
|
+
configure_signals(app, cli)
|
|
1404
|
+
|
|
1405
|
+
main_window.tray.show()
|
|
1406
|
+
if show_ui:
|
|
1407
|
+
main_window.show()
|
|
1408
|
+
|
|
1409
|
+
if _exit:
|
|
1410
|
+
return
|
|
1411
|
+
else:
|
|
1412
|
+
app.exec()
|
|
1413
|
+
api.stop()
|
|
1414
|
+
cli.stop()
|
|
1415
|
+
sys.exit()
|
|
1416
|
+
|
|
1417
|
+
except SemaphoreAcquireError:
|
|
1418
|
+
# The semaphore already exists, so another instance is running
|
|
1419
|
+
sys.exit()
|
|
1420
|
+
|
|
1421
|
+
|
|
1422
|
+
if __name__ == "__main__":
|
|
1423
|
+
start()
|