streamdeck-gui-ng 4.1.3__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (62) hide show
  1. streamdeck_gui_ng-4.1.3.dist-info/METADATA +141 -0
  2. streamdeck_gui_ng-4.1.3.dist-info/RECORD +62 -0
  3. streamdeck_gui_ng-4.1.3.dist-info/WHEEL +4 -0
  4. streamdeck_gui_ng-4.1.3.dist-info/entry_points.txt +4 -0
  5. streamdeck_gui_ng-4.1.3.dist-info/licenses/LICENSE +21 -0
  6. streamdeck_ui/__init__.py +6 -0
  7. streamdeck_ui/api.py +712 -0
  8. streamdeck_ui/button.ui +1214 -0
  9. streamdeck_ui/cli/__init__.py +0 -0
  10. streamdeck_ui/cli/commands.py +191 -0
  11. streamdeck_ui/cli/server.py +292 -0
  12. streamdeck_ui/config.py +244 -0
  13. streamdeck_ui/dimmer.py +93 -0
  14. streamdeck_ui/display/__init__.py +0 -0
  15. streamdeck_ui/display/background_color_filter.py +41 -0
  16. streamdeck_ui/display/display_grid.py +265 -0
  17. streamdeck_ui/display/empty_filter.py +43 -0
  18. streamdeck_ui/display/filter.py +65 -0
  19. streamdeck_ui/display/image_filter.py +144 -0
  20. streamdeck_ui/display/keypress_filter.py +63 -0
  21. streamdeck_ui/display/pipeline.py +74 -0
  22. streamdeck_ui/display/pulse_filter.py +54 -0
  23. streamdeck_ui/display/text_filter.py +142 -0
  24. streamdeck_ui/fonts/roboto/LICENSE.txt +202 -0
  25. streamdeck_ui/fonts/roboto/Roboto-Black.ttf +0 -0
  26. streamdeck_ui/fonts/roboto/Roboto-BlackItalic.ttf +0 -0
  27. streamdeck_ui/fonts/roboto/Roboto-Bold.ttf +0 -0
  28. streamdeck_ui/fonts/roboto/Roboto-BoldItalic.ttf +0 -0
  29. streamdeck_ui/fonts/roboto/Roboto-Italic.ttf +0 -0
  30. streamdeck_ui/fonts/roboto/Roboto-Light.ttf +0 -0
  31. streamdeck_ui/fonts/roboto/Roboto-LightItalic.ttf +0 -0
  32. streamdeck_ui/fonts/roboto/Roboto-Medium.ttf +0 -0
  33. streamdeck_ui/fonts/roboto/Roboto-MediumItalic.ttf +0 -0
  34. streamdeck_ui/fonts/roboto/Roboto-Regular.ttf +0 -0
  35. streamdeck_ui/fonts/roboto/Roboto-Thin.ttf +0 -0
  36. streamdeck_ui/fonts/roboto/Roboto-ThinItalic.ttf +0 -0
  37. streamdeck_ui/gui.py +1423 -0
  38. streamdeck_ui/icons/add_page.png +0 -0
  39. streamdeck_ui/icons/cross.png +0 -0
  40. streamdeck_ui/icons/gear.png +0 -0
  41. streamdeck_ui/icons/horizontal-align.png +0 -0
  42. streamdeck_ui/icons/remove_page.png +0 -0
  43. streamdeck_ui/icons/vertical-align.png +0 -0
  44. streamdeck_ui/icons/warning_icon_button.png +0 -0
  45. streamdeck_ui/logger.py +11 -0
  46. streamdeck_ui/logo.png +0 -0
  47. streamdeck_ui/main.ui +407 -0
  48. streamdeck_ui/mock_streamdeck.py +204 -0
  49. streamdeck_ui/model.py +78 -0
  50. streamdeck_ui/modules/__init__.py +0 -0
  51. streamdeck_ui/modules/fonts.py +150 -0
  52. streamdeck_ui/modules/keyboard.py +447 -0
  53. streamdeck_ui/modules/utils/__init__.py +0 -0
  54. streamdeck_ui/modules/utils/timers.py +35 -0
  55. streamdeck_ui/resources.qrc +10 -0
  56. streamdeck_ui/resources_rc.py +324 -0
  57. streamdeck_ui/semaphore.py +38 -0
  58. streamdeck_ui/settings.ui +155 -0
  59. streamdeck_ui/stream_deck_monitor.py +157 -0
  60. streamdeck_ui/ui_button.py +421 -0
  61. streamdeck_ui/ui_main.py +267 -0
  62. streamdeck_ui/ui_settings.py +119 -0
streamdeck_ui/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()