-
Notifications
You must be signed in to change notification settings - Fork 0
/
switchboard.py
1783 lines (1469 loc) · 70.4 KB
/
switchboard.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
# !/usr/bin/python
# coding=utf-8
import re
import sys
import json
import logging
import traceback
from typing import List, Union
from inspect import signature, Parameter
from xml.etree.ElementTree import ElementTree
from PySide2 import QtCore, QtGui, QtWidgets, QtUiTools
import pythontk as ptk
from uitk.file_manager import FileManager
from uitk.widgets.mixins.convert import ConvertMixin
class Switchboard(QtUiTools.QUiLoader, ptk.HelpMixin):
"""Switchboard is a dynamic UI loader and event handler for PyQt/PySide applications.
It facilitates the loading of UI files, dynamic assignment of properties, and
management of signal-slot connections in a modular and organized manner.
This class streamlines the process of integrating UI files created with Qt Designer,
custom widget classes, and Python slot classes into your application. It adds convenience
methods and properties to each slot class instance, enabling easy access to the Switchboard's
functionality within the slots class.
Attributes:
default_signals (dict): Default signals to be connected per widget type when no specific signals are defined.
module_dir (str): Directory of this module.
default_dir (str): Default directory used for relative path resolution.
Example:
- Creating a subclass of Switchboard to load project UI and connect slots:
class MyProjectUi:
def __new__(cls, *args, **kwargs):
sb = Switchboard(*args, ui_location="my_project.ui", **kwargs)
ui = sb.my_project
ui.set_attributes(WA_TranslucentBackground=True)
ui.set_flags(Tool=True, FramelessWindowHint=True, WindowStaysOnTopHint=True)
ui.set_style(theme="dark", style_class="translucentBgWithBorder")
return ui
- Instantiating and displaying the UI:
ui = MyProjectUi(parent)
ui.show(pos="screen", app_exec=True)
"""
QtCore = QtCore
QtGui = QtGui
QtWidgets = QtWidgets
QtUiTools = QtUiTools
# Use the existing QApplication object, or create a new one if none exists.
app = QtWidgets.QApplication.instance() or QtWidgets.QApplication(sys.argv)
default_signals = { # the signals to be connected per widget type should no signals be specified using the slot decorator.
QtWidgets.QAction: "triggered",
QtWidgets.QLabel: "released",
QtWidgets.QPushButton: "clicked",
QtWidgets.QListWidget: "itemClicked",
QtWidgets.QTreeWidget: "itemClicked",
QtWidgets.QComboBox: "currentIndexChanged",
QtWidgets.QSpinBox: "valueChanged",
QtWidgets.QDoubleSpinBox: "valueChanged",
QtWidgets.QCheckBox: "stateChanged",
QtWidgets.QRadioButton: "toggled",
QtWidgets.QLineEdit: "textChanged",
QtWidgets.QTextEdit: "textChanged",
QtWidgets.QSlider: "valueChanged",
QtWidgets.QProgressBar: "valueChanged",
QtWidgets.QDial: "valueChanged",
QtWidgets.QScrollBar: "valueChanged",
QtWidgets.QDateEdit: "dateChanged",
QtWidgets.QDateTimeEdit: "dateTimeChanged",
QtWidgets.QTimeEdit: "timeChanged",
QtWidgets.QMenu: "triggered",
QtWidgets.QMenuBar: "triggered",
QtWidgets.QTabBar: "currentChanged",
QtWidgets.QTabWidget: "currentChanged",
QtWidgets.QToolBox: "currentChanged",
QtWidgets.QStackedWidget: "currentChanged",
}
def __init__(
self,
parent=None,
ui_location=None,
slot_location=None,
widget_location=None,
ui_name_delimiters=[".", "#"],
log_level=logging.WARNING,
):
super().__init__(parent)
""" """
self._init_logger(log_level)
self.registry = FileManager()
base_dir = 1 if not __name__ == "__main__" else 0
self.registry.create(
"ui_registry",
ui_location,
inc_files="*.ui",
base_dir=base_dir,
)
self.registry.create(
"slot_registry",
slot_location,
fields=["classname", "classobj", "filename", "filepath"],
inc_files="*.py",
base_dir=base_dir,
)
self.registry.create(
"widget_registry",
widget_location,
fields=["classname", "classobj", "filename", "filepath"],
inc_files="*.py",
base_dir=base_dir,
)
# Include this packages widgets.
self.registry.widget_registry.extend("widgets", base_dir=0)
self.ui_name_delimiters = ui_name_delimiters
self._loaded_ui = {} # All loaded ui.
self._ui_history = [] # Ordered ui history.
self._registered_widgets = {} # All registered custom widgets.
self._slot_history = [] # Previously called slots.
self._connected_slots = {} # Currently connected slots.
self._synced_pairs = set() # Hashed values representing synced widgets.
self._gc_protect = set() # Objects protected from garbage collection.
self.convert = ConvertMixin()
def _init_logger(self, log_level):
"""Initializes logger with the specified log level.
Parameters:
log_level (int): Logging level.
"""
self.logger = logging.getLogger(__name__)
self.logger.setLevel(log_level)
handler = logging.StreamHandler()
handler.setFormatter(
logging.Formatter("%(asctime)s - %(name)s - %(levelname)s - %(message)s")
)
self.logger.addHandler(handler)
def __getattr__(self, attr_name):
"""Lazy load UI, custom widgets, and Qt module attributes.
The attribute resolution order is as follows:
1. If an unknown attribute matches the name of a UI in the current UI directory, load and return it.
2. Else, if an unknown attribute matches the name of a custom widget in the widgets directory, register and return it.
If no match is found in any of these categories, an AttributeError is raised.
Returns:
(obj) The attribute could be a UI, a custom widget, or a Qt module attribute.
"""
# Check if the attribute matches a UI file
actual_ui_name = self.convert_from_legal_name(attr_name, unique_match=True)
if actual_ui_name:
ui_filepath = self.registry.ui_registry.get(
filename=actual_ui_name, return_field="filepath"
)
if ui_filepath:
ui = self.load_ui(ui_filepath)
return ui
# Check if the attribute matches a widget file
widget_class = self.registry.widget_registry.get(
classname=attr_name, return_field="classobj"
)
if widget_class:
widget = self.register_widget(widget_class)
return widget
raise AttributeError(
f"{self.__class__.__name__} has no attribute `{attr_name}`"
)
@property
def current_ui(self) -> QtWidgets.QWidget:
"""Get the current UI.
Returns:
(obj) UI
"""
return self.get_current_ui()
@current_ui.setter
def current_ui(self, ui) -> None:
"""Register the uiName in history as current and set slot connections.
Parameters:
ui (QWidget): A previously loaded dynamic ui object.
"""
if not isinstance(ui, QtWidgets.QWidget):
raise ValueError(f"Invalid datatype: Expected QWidget, got {type(ui)}")
self.set_current_ui(ui)
@property
def prev_ui(self) -> QtWidgets.QWidget:
"""Get the previous UI from history.
Returns:
(obj)
"""
return self.ui_history(-1)
@property
def prev_slot(self) -> object:
"""Get the last called slot.
Returns:
(obj) method.
"""
try:
return self.slot_history(-1)
except IndexError:
return None
@staticmethod
def get_base_name(widget) -> str:
"""Return the base name of a widget's object name.
A base name is defined as a character sequence at the beginning of the widget's object name,
ending at the last letter character.
Parameters:
widget (str/obj): The widget or its object name as a string.
Returns:
(str) The base name of the widget's object name as a string.
Example:
get_base_name('some_name') #returns: 'some_name'
get_base_name('some_name_') #returns: 'some_name'
get_base_name('some_name_03') #returns: 'some_name'
"""
if not isinstance(widget, str):
widget = widget.objectName()
match = re.search(r"^\w*[a-zA-Z]", widget)
return match.group() if match else widget
def convert_to_legal_name(self, name: str) -> str:
"""Convert the given name to its legal representation, replacing any non-alphanumeric characters with underscores.
Parameters:
name (str): The name to convert.
Returns:
str: The legal name with non-alphanumeric characters replaced by underscores.
"""
return re.sub(r"[^0-9a-zA-Z]", "_", name)
def convert_from_legal_name(
self, legal_name: str, unique_match: bool = False
) -> Union[str, List[str], None]:
"""Convert the given legal name to its original name(s) by searching the UI files.
Parameters:
legal_name (str): The legal name to convert back to the original name.
unique_match (bool, optional): If True, return None when there is more than one possible match, otherwise, return all possible matches. Defaults to False.
Returns:
Union[str, List[str], None]: The original name(s) or None if unique_match is True and multiple matches are found.
"""
# Replace underscores with a regex pattern to match any non-alphanumeric character
pattern = re.sub(r"_", r"[^0-9a-zA-Z]", legal_name)
# Retrieve all filenames from the ui_registry container
filenames = self.registry.ui_registry.get("filename")
# Find matches using the regex pattern
matches = [name for name in filenames if re.fullmatch(pattern, name)]
if unique_match:
return None if len(matches) != 1 else matches[0]
else:
return matches
@staticmethod
def get_property_from_ui_file(file, prop):
"""Retrieves a specified property from a given UI or XML file.
This method parses the given file, expecting it to be in either .ui or .xml format,
and searches for all elements with the specified property. It then returns a list
of these elements, where each element is represented as a list of tuples containing
the tag and text of its sub-elements.
Parameters:
file (str): The path to the UI or XML file to be parsed. The file must have a .ui or .xml extension.
prop (str): The property to search for within the file.
Returns:
list: A list of lists containing tuples with the tag and text of the sub-elements of each element found with the specified property.
Raises:
ValueError: If the file extension is not .ui or .xml, or if there is an error in parsing the file.
Example:
get_property_from_ui_file('example.ui', 'customwidget')
# Output: [[('class', 'CustomWidget'), ('extends', 'QWidget')], ...]
"""
if not (file.endswith(".ui") or file.endswith(".xml")):
raise ValueError(
f"Invalid file extension. Expected a .ui or .xml file, got: {file}"
)
tree = ElementTree()
tree.parse(file)
# Find all elements with the given property
elements = tree.findall(".//{}".format(prop))
result = []
for elem in elements:
prop_list = []
for subelem in elem:
prop_list.append((subelem.tag, subelem.text))
result.append(prop_list)
return result
def load_all_ui(self) -> list:
"""Extends the 'load_ui' method to load all UI from a given path.
Returns:
(list) QWidget(s).
"""
filepaths = self.registry.ui_registry.get("filepath")
return [self.load_ui(f) for f in filepaths]
def load_ui(self, file) -> QtWidgets.QWidget:
"""Loads a UI from the given path to the UI file.
Parameters:
file (str): The full file path to the UI file.
Returns:
(obj) QWidget.
"""
# Get any custom widgets from the UI file.
lst = self.get_property_from_ui_file(file, "customwidget")
for sublist in lst:
try:
class_name = sublist[0][1]
except IndexError:
continue
widget_class_info = self.registry.widget_registry.get(
classname=class_name, return_field="classobj"
)
if widget_class_info and class_name not in self._registered_widgets:
widget_class = widget_class_info
self.register_widget(widget_class)
ui = self.MainWindow(
self,
file,
log_level=self.logger.getEffectiveLevel(),
)
self._loaded_ui[ui.name] = ui
return ui
def get_ui(self, ui=None) -> QtWidgets.QWidget:
"""Get a dynamic UI using its string name, or if no argument is given, return the current UI.
Parameters:
ui (str/list/QWidget): The UI or name(s) of the UI.
Raises:
ValueError: If the given UI is of an incorrect datatype.
Returns:
(obj/list): If a list is given, a list is returned. Otherwise, a QWidget object is returned.
"""
if isinstance(ui, QtWidgets.QWidget):
return ui
elif isinstance(ui, str):
return getattr(self, ui)
elif isinstance(ui, (list, set, tuple)):
return [self.get_ui(u) for u in ui]
elif ui is None:
return self.current_ui
else:
raise ValueError(
f"Invalid datatype for ui: Expected str or QWidget, got {type(ui)}"
)
def get_current_ui(self) -> QtWidgets.QWidget:
"""Get the current UI.
Returns:
(obj): A previously loaded dynamic UI object.
"""
try:
return self._current_ui
except AttributeError:
# If only one UI is loaded, set that UI as current.
if len(self._loaded_ui) == 1:
ui = next(iter(self._loaded_ui.values()))
self.set_current_ui(ui)
return ui
# if a single UI has been added, but not yet loaded; load and set it as current.
filepaths = self.registry.ui_registry.get("filepath")
if len(filepaths) == 1:
ui = self.load_ui(filepaths[0])
self.set_current_ui(ui)
return ui
return None
def set_current_ui(self, ui) -> None:
"""Register the specified dynamic UI as the current one in the application's history.
Once registered, the UI widget can be accessed through the `current_ui` property while it remains the current UI.
If the given UI is already the current UI, the method simply returns without making any changes.
Parameters:
ui (QWidget): A previously loaded dynamic UI object.
"""
if not isinstance(ui, QtWidgets.QWidget):
raise ValueError(f"Invalid datatype: Expected QWidget, got {type(ui)}")
current_ui = getattr(self, "_current_ui", None)
if current_ui == ui:
return
self._current_ui = ui
self._ui_history.append(ui)
# self.logger.info(f"_ui_history: {u.name for u in self._ui_history}") # debug
def register(
self, ui_location=None, slot_location=None, widget_location=None, base_dir=1
):
"""Add new locations to the Switchboard.
Parameters:
ui_location (optional): Path to the UI file.
slot_location (optional): Slot class.
widget_location (optional): Path to widget files.
base_dir (optional): Base directory for relative paths. Derived from the call stack.
0 for this modules dir, 1 for the caller module, etc. (duplicate entries removed)
"""
# Check for the existence of the ui_location before extending the UI files container
if ui_location is not None and not self.registry.contains_location(
ui_location, "ui_registry"
):
self.registry.ui_registry.extend(ui_location, base_dir=base_dir)
# Check for the existence of the slot_location before extending the slot files container
if slot_location is not None and not self.registry.contains_location(
slot_location, "slot_registry"
):
self.registry.slot_registry.extend(slot_location, base_dir=base_dir)
# Check for the existence of the widget_location before extending the widget files container
if widget_location is not None and not self.registry.contains_location(
widget_location, "widget_registry"
):
self.registry.widget_registry.extend(widget_location, base_dir=base_dir)
def get_ui_relatives(
self, ui, upstream=False, exact=False, downstream=False, reverse=False
):
"""Get the UI relatives based on the hierarchy matching.
Parameters:
ui (str or obj): A dynamic UI object or its name for which relatives are to be found.
upstream (bool, optional): If True, return the relatives that are upstream of the target UI. Defaults to False.
exact (bool, optional): If True, return only the relatives that exactly match the target UI. Defaults to False.
downstream (bool, optional): If True, return the relatives that are downstream of the target UI. Defaults to False.
reverse (bool, optional): If True, search for relatives in the reverse direction. Defaults to False.
Returns:
list: A list of UI relative names (if ui is given as a string) or UI relative objects (if ui is given as an object) found based on the hierarchy matching.
"""
ui_name = str(ui)
ui_filenames = self.registry.ui_registry.get(
"filename"
) # Get the filenames from the named tuple
relatives = ptk.get_matching_hierarchy_items(
ui_filenames,
ui_name,
upstream,
exact,
downstream,
reverse,
self.ui_name_delimiters,
)
# Return strings if ui given as a string, else UI objects.
return relatives if ui_name == ui else self.get_ui(relatives)
def ui_history(self, index=None, allow_duplicates=False, inc=[], exc=[]):
"""Get the UI history.
Parameters:
index (int/slice, optional): Index or slice to return from the history. If not provided, returns the full list.
allow_duplicates (bool): When returning a list, allows for duplicate names in the returned list.
inc (str/list): The objects(s) to include.
supports using the '*' operator: startswith*, *endswith, *contains*
Will include all items that satisfy ANY of the given search terms.
meaning: '*.png' and '*Normal*' returns all strings ending in '.png' AND all
strings containing 'Normal'. NOT strings satisfying both terms.
exc (str/list): The objects(s) to exclude. Similar to include.
exclude take precedence over include.
Returns:
(str/list): String of a single UI name or list of UI names based on the index or slice.
Examples:
ui_history() -> ['previousName4', 'previousName3', 'previousName2', 'previousName1', 'currentName']
ui_history(-2) -> 'previousName1'
ui_history(slice(-3, None)) -> ['previousName2', 'previousName1', 'currentName']
"""
# Keep original list length restricted to last 200 elements
self._ui_history = self._ui_history[-200:]
# Remove any previous duplicates if they exist; keeping the last added element.
if not allow_duplicates:
self._ui_history = list(dict.fromkeys(self._ui_history[::-1]))[::-1]
history = self._ui_history
if inc or exc:
history = ptk.filter_list(history, inc, exc, lambda u: u.name)
if index is None:
return history # Return entire list if index is None
else:
try:
return history[index] # Return UI(s) based on the index
except IndexError:
return [] if isinstance(index, int) else None
def slot_history(
self, index=None, allow_duplicates=False, inc=[], exc=[], add=[], remove=[]
):
"""Get the slot history.
Parameters:
index (int/slice, optional): Index or slice to return from the history. If not provided, returns the full list.
allow_duplicates (bool): When returning a list, allows for duplicate names in the returned list.
inc (str/int/list): The objects(s) to include.
supports using the '*' operator: startswith*, *endswith, *contains*
Will include all items that satisfy ANY of the given search terms.
meaning: '*.png' and '*Normal*' returns all strings ending in '.png' AND all
strings containing 'Normal'. NOT strings satisfying both terms.
exc (str/int/list): The objects(s) to exclude. Similar to include.
exclude take precedence over include.
add (object/list, optional): New entrie(s) to append to the slot history.
remove (object/list, optional): Entry/entries to remove from the slot history.
Returns:
(object/list): Slot method(s) based on index or slice.
"""
# Keep original list length restricted to last 200 elements
self._slot_history = self._slot_history[-200:]
# Append new entries to the history
if add:
self._slot_history.extend(ptk.make_iterable(add))
# Remove entries from the history
if remove:
remove_items = ptk.make_iterable(remove)
for item in remove_items:
try:
self._slot_history.remove(item)
except ValueError:
print(f"Item {item} not found in history.")
# Remove any previous duplicates if they exist; keeping the last added element.
if not allow_duplicates:
self._slot_history = list(dict.fromkeys(self._slot_history[::-1]))[::-1]
history = self._slot_history
if inc or exc:
history = ptk.filter_list(
history, inc, exc, lambda m: m.__name__, check_unmapped=True
)
if index is None:
return history # Return entire list if index is None
else:
try:
return history[index] # Return slot(s) based on the index
except IndexError:
return [] if isinstance(index, int) else None
def set_slot_class(self, ui, clss):
"""This method sets the slot class instance for a loaded dynamic UI object. It takes a UI and
a class and sets the instance as the slots for the given UI. Finally, it
initializes the widgets and returns the slot class instance.
Parameters:
ui (QWidget): A previously loaded dynamic UI object.
clss (class): A class that will be set as the slots for the given UI.
Returns:
object: An instance of the given class.
Attributes:
switchboard (method): A method in the slot class that returns the Switchboard instance.
"""
if not isinstance(ui, QtWidgets.QWidget):
raise ValueError(f"Invalid datatype: Expected QWidget, got {type(ui)}")
# Make this switchboard instance accessible through the class.
clss.switchboard = lambda *args: self
# Instance the class
instance = clss()
# Make the class accessible through this switchboard instance.
setattr(self, clss.__name__, instance)
# Assign the instance to <ui>._slots save it.
ui._slots = instance
return instance
def get_slot_class(self, ui):
"""This function tries to get a class instance of the slots module from a dynamic UI object.
If it doesn't exist, it tries to import it from a specified location or from the parent menu.
If it's found, it's set and returned, otherwise None is returned.
Parameters:
ui (QWidget): A previously loaded dynamic UI object.
Returns:
object: A class instance.
"""
if not isinstance(ui, QtWidgets.QWidget):
raise ValueError(f"Invalid datatype: Expected QWidget, got {type(ui)}")
if hasattr(ui, "_slots"):
return ui._slots
try:
found_class = self._find_slot_class(ui)
except ValueError:
self.logger.info(traceback.format_exc())
found_class = None
if not found_class:
for relative_name in self.get_ui_relatives(ui, upstream=True, reverse=True):
relative_ui = self.get_ui(relative_name)
if relative_ui:
try:
found_class = self._find_slot_class(relative_ui)
break
except ValueError:
self.logger.info(traceback.format_exc())
if found_class:
slots_instance = self.set_slot_class(ui, found_class)
return slots_instance
else:
ui._slots = None
return None
def _find_slot_class(self, ui):
"""Find the slot class associated with the given UI by following a specific naming convention.
This method takes a dynamic UI object and retrieves the associated slot class based on
the UI's legal name without tags (<ui>.legal_name_no_tags). The method constructs possible
class names by capitalizing each word, removing underscores, and considering both the
original name and a version with the 'Slots' suffix.
For example, if the UI's legal name without tags is 'polygons', the method will search
for slot classes named 'PolygonsSlots' and 'Polygons' within the slots_directory. The
search is conducted in the following order:
1. <legal_name_notags>Slots
2. <legal_name_notags>
Parameters:
ui (QWidget): A previously loaded dynamic UI object containing attributes such as
legal_name_no_tags that describe the UI's name.
Returns:
object: The matched slot class, if found. If no corresponding slot class is found,
the method returns None.
"""
possible_class_name = ui.legal_name_no_tags.title().replace("_", "")
try_names = [f"{possible_class_name}Slots", possible_class_name]
for name in try_names:
found_class = self.registry.slot_registry.get(
classname=name, return_field="classobj"
)
if found_class:
return found_class
return None
def get_available_signals(self, widget, derived=True, exc=[]):
"""Get all available signals for a type of widget.
Parameters:
widget (str/obj): The widget to get signals for.
derived (bool): Return signals from all derived classes instead of just the given widget class.
ex. get: QObject, QWidget, QAbstractButton, QPushButton signals from 'QPushButton'
exc (list): Exclude any classes in this list. ex. exc=[QtCore.QObject, 'QWidget']
Returns:
(set)
Example: get_available_signals(QtWidgets.QPushButton)
would return:
clicked (QAbstractButton)
pressed (QAbstractButton)
released (QAbstractButton)
toggled (QAbstractButton)
customContextMenuRequested (QWidget)
windowIconChanged (QWidget)
windowIconTextChanged (QWidget)
windowTitleChanged (QWidget)
destroyed (QObject)
objectNameChanged (QObject)
"""
signals = set()
clss = widget if isinstance(widget, type) else type(widget)
signal_type = type(QtCore.Signal())
for subcls in clss.mro():
clsname = f"{subcls.__module__}.{subcls.__name__}"
for k, v in sorted(vars(subcls).items()):
if isinstance(v, signal_type):
if (not derived and clsname != clss.__name__) or (
exc and (clss in exc or clss.__name__ in exc)
): # if signal is from parent class QAbstractButton and given widget is QPushButton:
continue
signals.add(k)
return signals
def get_default_signals(self, widget):
"""Retrieves the default signals for a given widget type.
This method iterates over a dictionary of default signals, which maps widget types to signal names.
If the widget is an instance of a type in the dictionary, the method checks if the widget has a signal
with the corresponding name. If it does, the signal is added to a set of signals.
The method returns this set of signals, which represents all the default signals that the widget has.
Parameters:
widget (QWidget): The widget to get the default signals for.
Returns:
set: A set of signals that the widget has, according to the default signals dictionary.
"""
signals = set()
for widget_type, signal_name in self.default_signals.items():
if isinstance(widget, widget_type):
signal = getattr(widget, signal_name, None)
if signal is not None:
signals.add(signal)
return signals
def connect_slots(self, ui, widgets=None):
"""Connects the default slots to their corresponding signals for all widgets of a given UI.
This method iterates over all widgets of the UI, and for each widget, it calls the `connect_slot` method
to connect the widget's default slot to its corresponding signal.
If a specific set of widgets is provided, the method only connects the slots for these widgets.
After all slots are connected, the method sets the `is_connected` attribute of the UI to True.
Parameters:
ui (QtWidgets.QWidget): The UI to connect the slots for.
widgets (Iterable[QtWidgets.QWidget], optional): A specific set of widgets to connect the slots for.
If not provided, all widgets of the UI are used.
Raises:
ValueError: If the UI is not an instance of QtWidgets.QWidget.
Side effect:
If successful, sets `<ui>.is_connected` to True, indicating that the slots for the UI's widgets are connected.
"""
if not isinstance(ui, QtWidgets.QWidget):
raise ValueError(f"Invalid datatype: {type(ui)}")
if widgets is None:
if ui.is_connected:
return
widgets = ui.widgets
for widget in ptk.make_iterable(widgets):
self.connect_slot(widget)
ui.is_connected = True
def _create_slot_wrapper(self, slot, widget):
"""Creates a wrapper function for a slot that includes the widget as a parameter if possible.
Parameters:
slot (callable): The slot function to be wrapped.
widget (QWidget): The widget that the slot is connected to.
Returns:
callable: The slot wrapper function.
"""
sig = signature(slot)
param_names = [
name
for name, param in sig.parameters.items()
if param.kind in [Parameter.POSITIONAL_OR_KEYWORD, Parameter.KEYWORD_ONLY]
]
def wrapper(*args, **kwargs):
# Check if the only parameter is 'widget'
if len(param_names) == 1 and "widget" in param_names:
# Call the slot with only the 'widget' argument
return slot(widget)
# Otherwise, prepare arguments normally
filtered_kwargs = {k: v for k, v in kwargs.items() if k in param_names}
if "widget" in param_names and "widget" not in kwargs:
filtered_kwargs["widget"] = widget
self.slot_history(add=slot)
return slot(*args, **filtered_kwargs)
return wrapper
def connect_slot(self, widget, slot=None):
"""Connects a slot to its associated signals for a widget.
The signals to be connected are defined in the slot's 'signals' attribute.
If the slot doesn't have a 'signals' attribute, the widget's default signals are used instead.
If a signal name isn't a string or no valid signal is found for the widget, a warning is logged.
If the slot is not provided, it will attempt to use the default slot associated with the widget.
If no slot is found, a ValueError is raised.
Parameters:
widget (QWidget): The widget to connect the slot to.
slot (object, optional): The slot to be connected. If not provided, the default slot associated with the widget will be used.
Raises:
ValueError: If no slot is found for the widget.
"""
if not slot:
slot = widget.get_slot()
if not slot:
self.logger.info(
f"No slot found for widget {widget.ui.name}.{widget.name}"
)
return
signals = getattr(
slot,
"signals",
ptk.make_iterable(self.default_signals.get(widget.derived_type)),
)
for signal_name in signals:
if not isinstance(signal_name, str):
raise TypeError(
f"Invalid signal for '{widget.ui.name}.{widget.name}' {widget.derived_type}. Expected str, got '{type(signal_name)}'"
)
signal = getattr(widget, signal_name, None)
if signal:
slot_wrapper = self._create_slot_wrapper(slot, widget)
signal.connect(slot_wrapper)
if widget not in self._connected_slots:
self._connected_slots[widget] = {}
self._connected_slots[widget][signal_name] = slot_wrapper
else:
self.logger.warning(
f"No valid signal found for '{widget.ui.name}.{widget.name}' {widget.derived_type}. Expected str, got '{type(signal_name)}'"
)
def disconnect_slots(self, ui, widgets=None, disconnect_all=False):
"""Disconnects the signals from their respective slots for the widgets of the given UI.
Only disconnects the slots that are connected via `connect_slots` unless disconnect_all is True.
Parameters:
ui (QWidget): A previously loaded dynamic UI object.
widgets (Iterable[QWidget], optional): A specific set of widgets for which
to disconnect slots. If not provided, all widgets from the UI are used.
disconnect_all (bool, optional): If True, disconnects all slots regardless of their connection source.
Raises:
ValueError: If `ui` is not an instance of QWidget.
Side effect:
If successful, sets `<ui>.is_connected` to False indicating that
the slots for the UI's widgets are disconnected.
"""
if not isinstance(ui, QtWidgets.QWidget):
raise ValueError(f"Invalid datatype: Expected QWidget, got {type(ui)}")
if widgets is None:
if not ui.is_connected:
return
widgets = ui.widgets
for widget in ptk.make_iterable(widgets):
slot = widget.get_slot()
if not slot:
continue
if disconnect_all:
for signal_name, slot in self._connected_slots.get(widget, {}).items():
getattr(widget, signal_name).disconnect(slot)
else:
signals = getattr(slot, "signals", self.get_default_signals(widget))
for signal_name in signals:
if signal_name in self._connected_slots.get(widget, {}):
getattr(widget, signal_name).disconnect(slot)
ui.is_connected = False
@staticmethod
def _get_widgets_from_ui(
ui: QtWidgets.QWidget, inc=[], exc="_*", object_names_only=False
) -> dict:
"""Find widgets in a PySide2 UI object.
Parameters:
ui (QWidget): A previously loaded dynamic UI object.
inc (str)(tuple): Widget names to include.
exc (str)(tuple): Widget names to exclude.
object_names_only (bool): Only include widgets with object names.
Returns:
(dict) {<widget>:'objectName'}
"""
if not isinstance(ui, QtWidgets.QWidget):
raise ValueError(f"Invalid datatype: {type(ui)}")
dct = {
c: c.objectName()
for c in ui.findChildren(QtWidgets.QWidget, None)
if (not object_names_only or c.objectName())
}
return ptk.filter_dict(dct, inc, exc, keys=True, values=True)
@staticmethod
def _get_widget_from_ui(
ui: QtWidgets.QWidget, object_name: str
) -> QtWidgets.QWidget:
"""Find a widget in a PySide2 UI object by its object name.
Parameters:
ui (QWidget): A previously loaded dynamic UI object.
object_name (str): The object name of the widget to find.
Returns:
(QWidget)(None) The widget object if it's found, or None if it's not found.
"""
if not isinstance(ui, QtWidgets.QWidget):
raise ValueError(f"Invalid datatype: {type(ui)}")
return ui.findChild(QtWidgets.QWidget, object_name)
def register_widget(self, widget):
"""Register any custom widgets using the module names.
Registered widgets can be accessed as properties. ex. sb.PushButton()
"""
if widget.__name__ not in self._registered_widgets:
self.registerCustomWidget(widget)
self._registered_widgets[widget.__name__] = widget
setattr(self, widget.__name__, widget)
return widget
def get_widget(self, name, ui=None):
"""Case insensitive. Get the widget object/s from the given UI and name.
Parameters:
name (str): The object name of the widget. ie. 'b000'
ui (str/obj): UI, or name of UI. ie. 'polygons'. If no nothing is given, the current UI will be used.
A UI object can be passed into this parameter, which will be used to get it's corresponding name.
Returns:
(obj) if name: widget object with the given name from the current UI.
if ui and name: widget object with the given name from the given UI name.
(list) if ui: all widgets for the given UI.
"""
if ui is None or isinstance(ui, str):
ui = self.get_ui(ui)
return next((w for w in ui.widgets if w.name == name), None)
def get_widget_from_method(self, method):
"""Get the corresponding widget from a given method.
Parameters:
method (obj): The method in which to get the widget of.
Returns:
(obj) widget. ie. <b000 widget> from <b000 method>.
"""
if not method:
return None
return next(
iter(
w
for u in self._loaded_ui.values()
for w in u.widgets
if w.get_slot() == method
),