jbqt 0.1.1__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.
Potentially problematic release.
This version of jbqt might be problematic. Click here for more details.
- jbqt/__init__.py +0 -0
- jbqt/common/__init__.py +6 -0
- jbqt/common/consts.py +167 -0
- jbqt/common/qt_utils.py +81 -0
- jbqt/dialogs/__init__.py +7 -0
- jbqt/dialogs/file_dialog.py +27 -0
- jbqt/dialogs/input_form.py +85 -0
- jbqt/dialogs/text_preview.py +42 -0
- jbqt/models/__init__.py +6 -0
- jbqt/models/chip_button.py +13 -0
- jbqt/models/chips.py +31 -0
- jbqt/view_icons.py +208 -0
- jbqt/widgets/__init__.py +16 -0
- jbqt/widgets/chip_button.py +204 -0
- jbqt/widgets/chips.py +232 -0
- jbqt/widgets/multiselect.py +201 -0
- jbqt/widgets/simple.py +67 -0
- jbqt/widgets/toast.py +35 -0
- jbqt/widgets/widget_utils.py +77 -0
- jbqt-0.1.1.dist-info/METADATA +16 -0
- jbqt-0.1.1.dist-info/RECORD +22 -0
- jbqt-0.1.1.dist-info/WHEEL +4 -0
jbqt/widgets/__init__.py
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
"""Collection of widget exports"""
|
|
2
|
+
|
|
3
|
+
from jbqt.widgets.chip_button import ChipButton
|
|
4
|
+
from jbqt.widgets.chips import ChipsWidget
|
|
5
|
+
from jbqt.widgets.multiselect import MultiSelectComboBox
|
|
6
|
+
from jbqt.widgets.simple import ClickableLabel, LongIntSpinBox
|
|
7
|
+
from jbqt.widgets.toast import Toast
|
|
8
|
+
|
|
9
|
+
__all__ = [
|
|
10
|
+
"ChipButton",
|
|
11
|
+
"ChipsWidget",
|
|
12
|
+
"MultiSelectComboBox",
|
|
13
|
+
"ClickableLabel",
|
|
14
|
+
"LongIntSpinBox",
|
|
15
|
+
"Toast",
|
|
16
|
+
]
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
from re import Pattern
|
|
2
|
+
from typing import Callable
|
|
3
|
+
|
|
4
|
+
from PyQt6.QtCore import Qt, QEvent, QObject
|
|
5
|
+
from PyQt6.QtGui import QKeyEvent
|
|
6
|
+
from PyQt6.QtWidgets import (
|
|
7
|
+
QHBoxLayout,
|
|
8
|
+
QLineEdit,
|
|
9
|
+
QPushButton,
|
|
10
|
+
QFrame,
|
|
11
|
+
QWidget,
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
from jbqt.common import consts
|
|
15
|
+
from jbqt.common.consts import STYLES, COLORS
|
|
16
|
+
from jbqt.models import IChipButton
|
|
17
|
+
from jbqt.widgets.simple import ClickableLabel
|
|
18
|
+
from jbqt.dialogs import InputDialog
|
|
19
|
+
from jbqt.widgets.widget_utils import (
|
|
20
|
+
debug_scroll_pos,
|
|
21
|
+
preserve_scroll,
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class ChipButton(IChipButton):
|
|
26
|
+
def __init__(
|
|
27
|
+
self,
|
|
28
|
+
tag: str,
|
|
29
|
+
on_update: Callable,
|
|
30
|
+
on_remove: Callable,
|
|
31
|
+
use_weight: bool = False,
|
|
32
|
+
weight_re: Pattern = None,
|
|
33
|
+
debug: bool = False,
|
|
34
|
+
):
|
|
35
|
+
super().__init__()
|
|
36
|
+
|
|
37
|
+
self._tag = tag
|
|
38
|
+
self.weight: float = 1.0
|
|
39
|
+
self.use_weight: bool = use_weight
|
|
40
|
+
|
|
41
|
+
if use_weight:
|
|
42
|
+
weight_re = weight_re or consts.TAG_RE
|
|
43
|
+
|
|
44
|
+
if weight_re:
|
|
45
|
+
tag_match = weight_re.search(tag)
|
|
46
|
+
|
|
47
|
+
if tag_match:
|
|
48
|
+
re_tag, weight = tag_match.groups()
|
|
49
|
+
self._tag = re_tag
|
|
50
|
+
self.weight = float(weight)
|
|
51
|
+
|
|
52
|
+
self.on_update = on_update
|
|
53
|
+
self.on_remove = on_remove
|
|
54
|
+
self.installEventFilter(self)
|
|
55
|
+
|
|
56
|
+
# Layout for the tag button
|
|
57
|
+
# self.frame = QFrame(self, Qt.WindowType.ToolTip)
|
|
58
|
+
self.frame = QFrame()
|
|
59
|
+
frame_layout = QHBoxLayout(self.frame)
|
|
60
|
+
frame_layout.setSpacing(5)
|
|
61
|
+
frame_layout.setContentsMargins(10, 0, 10, 0)
|
|
62
|
+
|
|
63
|
+
self.tag_label = ClickableLabel(tag)
|
|
64
|
+
self.tag_label.setFixedHeight(24)
|
|
65
|
+
self.tag_label.clicked.connect(self.toggle_edit_mode)
|
|
66
|
+
|
|
67
|
+
self.weight_button: QPushButton = None
|
|
68
|
+
|
|
69
|
+
if use_weight:
|
|
70
|
+
self.weight_button = QPushButton(consts.ICONS.CODE(), "")
|
|
71
|
+
self.weight_button.clicked.connect(self.apply_weight)
|
|
72
|
+
self.weight_button.setFixedWidth(24)
|
|
73
|
+
|
|
74
|
+
self.remove_button = QPushButton(consts.ICONS.TRASH(), "")
|
|
75
|
+
self.remove_button.clicked.connect(lambda: self.on_remove(self))
|
|
76
|
+
self.remove_button.setFixedWidth(24)
|
|
77
|
+
self.remove_button.setStyleSheet(STYLES.BG_DARK_RED)
|
|
78
|
+
|
|
79
|
+
self.edit_line = QLineEdit(self)
|
|
80
|
+
self.edit_line.hide()
|
|
81
|
+
|
|
82
|
+
self.confirm_btn = QPushButton(consts.ICONS.CIRCLE_CHECK(COLORS.WHITE), "")
|
|
83
|
+
self.confirm_btn.clicked.connect(self.edit_tag)
|
|
84
|
+
self.confirm_btn.setFixedWidth(24)
|
|
85
|
+
self.confirm_btn.setStyleSheet(STYLES.BG_DARK_GREEN)
|
|
86
|
+
self.confirm_btn.hide()
|
|
87
|
+
|
|
88
|
+
self.cancel_btn = QPushButton(consts.ICONS.CIRCLE_TIMES(), "")
|
|
89
|
+
self.cancel_btn.clicked.connect(self.cancel)
|
|
90
|
+
self.cancel_btn.setFixedWidth(24)
|
|
91
|
+
self.cancel_btn.setStyleSheet(STYLES.BG_DARK_RED)
|
|
92
|
+
self.cancel_btn.hide()
|
|
93
|
+
|
|
94
|
+
self.widgets = [
|
|
95
|
+
self.tag_label,
|
|
96
|
+
self.edit_line,
|
|
97
|
+
self.confirm_btn,
|
|
98
|
+
self.cancel_btn,
|
|
99
|
+
self.weight_button,
|
|
100
|
+
self.remove_button,
|
|
101
|
+
]
|
|
102
|
+
self.input_widgets = [self.edit_line, self.confirm_btn, self.cancel_btn]
|
|
103
|
+
for widget in self.widgets:
|
|
104
|
+
if widget:
|
|
105
|
+
frame_layout.addWidget(widget)
|
|
106
|
+
|
|
107
|
+
# Main layout
|
|
108
|
+
self.main_layout = QHBoxLayout(self)
|
|
109
|
+
self.main_layout.addWidget(self.frame)
|
|
110
|
+
self.main_layout.setAlignment(Qt.AlignmentFlag.AlignLeft)
|
|
111
|
+
# Apply styling to the frame
|
|
112
|
+
self.frame.setStyleSheet(
|
|
113
|
+
f"""
|
|
114
|
+
QFrame {{
|
|
115
|
+
border: 1px solid #AAA;
|
|
116
|
+
border-radius: 10px;
|
|
117
|
+
background-color: #343439;
|
|
118
|
+
max-height: 36px;
|
|
119
|
+
}}
|
|
120
|
+
QLabel {{
|
|
121
|
+
border: none;
|
|
122
|
+
}}
|
|
123
|
+
QPushButton {{
|
|
124
|
+
{STYLES.BG_GRAY}
|
|
125
|
+
}}
|
|
126
|
+
"""
|
|
127
|
+
)
|
|
128
|
+
self.setLayout(self.main_layout)
|
|
129
|
+
|
|
130
|
+
@property
|
|
131
|
+
def text(self) -> str:
|
|
132
|
+
if self.weight == 1:
|
|
133
|
+
return self._tag
|
|
134
|
+
return f"({self._tag}){self.weight:g}"
|
|
135
|
+
|
|
136
|
+
@text.setter
|
|
137
|
+
def text(self, value: str) -> None:
|
|
138
|
+
self._tag = value
|
|
139
|
+
|
|
140
|
+
def eventFilter(self, obj: QObject, event: QKeyEvent) -> bool:
|
|
141
|
+
focus = consts.GlobalRefs.app.focusWidget()
|
|
142
|
+
""" if event.type() == QEvent.Type.KeyPress:
|
|
143
|
+
print("focus", focus) """
|
|
144
|
+
if event.type() == QEvent.Type.KeyPress and focus == self.edit_line:
|
|
145
|
+
match event.key():
|
|
146
|
+
case Qt.Key.Key_Return | Qt.Key.Key_Enter:
|
|
147
|
+
self.edit_tag()
|
|
148
|
+
case Qt.Key.Key_Escape:
|
|
149
|
+
self.cancel()
|
|
150
|
+
return super().eventFilter(obj, event)
|
|
151
|
+
|
|
152
|
+
def toggle_hidden(self, hide: bool = True) -> None:
|
|
153
|
+
for widget in self.widgets:
|
|
154
|
+
is_input = widget in self.input_widgets
|
|
155
|
+
|
|
156
|
+
if hide == is_input:
|
|
157
|
+
widget.hide()
|
|
158
|
+
else:
|
|
159
|
+
widget.show()
|
|
160
|
+
|
|
161
|
+
def cancel(self) -> None:
|
|
162
|
+
self.toggle_hidden()
|
|
163
|
+
|
|
164
|
+
def toggle_edit_mode(self):
|
|
165
|
+
self.edit_line.setText(self._tag)
|
|
166
|
+
self.edit_line.setMinimumWidth(self.tag_label.width() + 10)
|
|
167
|
+
self.toggle_hidden(False)
|
|
168
|
+
|
|
169
|
+
def emit_update(self, text: str = None, weight: float = None) -> None:
|
|
170
|
+
text = text or self._tag
|
|
171
|
+
if weight is None:
|
|
172
|
+
weight = self.weight
|
|
173
|
+
if weight == "":
|
|
174
|
+
self.on_remove(self)
|
|
175
|
+
return
|
|
176
|
+
prev_text = self.text
|
|
177
|
+
self._tag = text
|
|
178
|
+
self.weight = weight
|
|
179
|
+
self.tag_label.setText(self.text)
|
|
180
|
+
self.on_update(prev_text, self.text)
|
|
181
|
+
|
|
182
|
+
def update_weight(self, weight: float) -> None:
|
|
183
|
+
self.emit_update(weight=weight)
|
|
184
|
+
|
|
185
|
+
@preserve_scroll
|
|
186
|
+
def edit_tag(self, *_):
|
|
187
|
+
text = self.edit_line.text()
|
|
188
|
+
self.toggle_hidden()
|
|
189
|
+
|
|
190
|
+
self.emit_update(text=text)
|
|
191
|
+
|
|
192
|
+
def apply_weight(self):
|
|
193
|
+
weight_dialog = InputDialog(
|
|
194
|
+
parent=self,
|
|
195
|
+
title="Tag Weight",
|
|
196
|
+
msg_str=f"Enter weight for `{self.text}`:",
|
|
197
|
+
input_type=float,
|
|
198
|
+
minimum=0,
|
|
199
|
+
maximum=2,
|
|
200
|
+
singleStep=0.1,
|
|
201
|
+
value=self.weight,
|
|
202
|
+
)
|
|
203
|
+
weight_dialog.connect(self.update_weight)
|
|
204
|
+
weight_dialog.exec()
|
jbqt/widgets/chips.py
ADDED
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
from re import Pattern
|
|
2
|
+
|
|
3
|
+
from PyQt6.QtCore import Qt, pyqtSignal, QObject, QEvent
|
|
4
|
+
from PyQt6.QtGui import QKeyEvent
|
|
5
|
+
from PyQt6.QtWidgets import (
|
|
6
|
+
QHBoxLayout,
|
|
7
|
+
QLabel,
|
|
8
|
+
QLineEdit,
|
|
9
|
+
QPushButton,
|
|
10
|
+
QScrollArea,
|
|
11
|
+
QVBoxLayout,
|
|
12
|
+
QWidget,
|
|
13
|
+
QComboBox,
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
import jb_utils as utils
|
|
17
|
+
from jbqt.common import consts
|
|
18
|
+
from jbqt.models import IChipsWidget
|
|
19
|
+
from jbqt.widgets.chip_button import ChipButton
|
|
20
|
+
from jbqt.widgets.multiselect import MultiSelectComboBox
|
|
21
|
+
from jbqt.widgets.widget_utils import debug_scroll_pos
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class ChipsWidget(IChipsWidget):
|
|
25
|
+
valuesChanged = pyqtSignal(list)
|
|
26
|
+
|
|
27
|
+
def __init__(
|
|
28
|
+
self,
|
|
29
|
+
chips=None,
|
|
30
|
+
data: dict = None,
|
|
31
|
+
path: str = "",
|
|
32
|
+
label: str = "",
|
|
33
|
+
weight_re: Pattern = None,
|
|
34
|
+
use_weight: bool = False,
|
|
35
|
+
debug: bool = False,
|
|
36
|
+
):
|
|
37
|
+
super().__init__()
|
|
38
|
+
self.values = chips or []
|
|
39
|
+
# Main layout
|
|
40
|
+
main_layout = QVBoxLayout()
|
|
41
|
+
self.debug = debug
|
|
42
|
+
if label:
|
|
43
|
+
main_layout.addWidget(QLabel(label))
|
|
44
|
+
self.installEventFilter(self)
|
|
45
|
+
|
|
46
|
+
self.use_weight = use_weight
|
|
47
|
+
if use_weight:
|
|
48
|
+
weight_re = weight_re or consts.TAG_RE
|
|
49
|
+
|
|
50
|
+
self.weight_re = weight_re
|
|
51
|
+
self.options = data
|
|
52
|
+
self.data = data or {}
|
|
53
|
+
self.data_path = path
|
|
54
|
+
self.list_widget: QComboBox | MultiSelectComboBox = None
|
|
55
|
+
|
|
56
|
+
if data is not None:
|
|
57
|
+
if isinstance(data, dict):
|
|
58
|
+
if path:
|
|
59
|
+
self.options = utils.get_nested(self.data, path, [])
|
|
60
|
+
|
|
61
|
+
self.list_widget = MultiSelectComboBox(
|
|
62
|
+
data=self.options, selected=self.values
|
|
63
|
+
)
|
|
64
|
+
self.list_widget.selectedChanged.connect(
|
|
65
|
+
self.handle_multiselect_change
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
elif isinstance(data, list):
|
|
69
|
+
self.list_widget = QComboBox()
|
|
70
|
+
|
|
71
|
+
# self.list_widget.addItems(self.options)
|
|
72
|
+
for option in self.options:
|
|
73
|
+
self.list_widget.addItem(option)
|
|
74
|
+
init_value = self.values[0] if self.values else ""
|
|
75
|
+
self.list_widget.setCurrentText(init_value)
|
|
76
|
+
self.list_widget.currentTextChanged.connect(
|
|
77
|
+
self.handle_multiselect_change
|
|
78
|
+
)
|
|
79
|
+
self.valuesChanged.connect(self.update_multiselect)
|
|
80
|
+
main_layout.addWidget(self.list_widget)
|
|
81
|
+
|
|
82
|
+
# Scroll area for the chips
|
|
83
|
+
scroll_area = QScrollArea()
|
|
84
|
+
scroll_area.setWidgetResizable(True)
|
|
85
|
+
|
|
86
|
+
# Widget inside the scroll area to hold the buttons
|
|
87
|
+
self.chip_widget = QWidget()
|
|
88
|
+
self.chip_layout = QHBoxLayout(self.chip_widget)
|
|
89
|
+
self.chip_layout.setAlignment(Qt.AlignmentFlag.AlignLeft)
|
|
90
|
+
|
|
91
|
+
# Add existing chips as buttons
|
|
92
|
+
self.set_items(emit=False)
|
|
93
|
+
|
|
94
|
+
scroll_area.setWidget(self.chip_widget)
|
|
95
|
+
|
|
96
|
+
# Layout for the input and add button
|
|
97
|
+
input_layout = QHBoxLayout()
|
|
98
|
+
self.input_field = QLineEdit(self)
|
|
99
|
+
self.add_button = QPushButton("Add", self)
|
|
100
|
+
self.add_button.clicked.connect(self.add_chip)
|
|
101
|
+
|
|
102
|
+
self.clear_button = QPushButton(consts.ICONS.TRASH("darkred"), "", self)
|
|
103
|
+
self.clear_button.clicked.connect(self.remove_all)
|
|
104
|
+
# self.clear_button.setStyleSheet("color: red")
|
|
105
|
+
|
|
106
|
+
input_layout.addWidget(self.input_field)
|
|
107
|
+
input_layout.addWidget(self.add_button)
|
|
108
|
+
input_layout.addWidget(self.clear_button)
|
|
109
|
+
|
|
110
|
+
main_layout.addWidget(scroll_area)
|
|
111
|
+
main_layout.addLayout(input_layout)
|
|
112
|
+
|
|
113
|
+
self.setLayout(main_layout)
|
|
114
|
+
|
|
115
|
+
def eventFilter(self, obj: QObject, event: QKeyEvent) -> bool:
|
|
116
|
+
focus = consts.GlobalRefs.app.focusWidget()
|
|
117
|
+
|
|
118
|
+
if event.type() == QEvent.Type.KeyPress and focus == self.input_field:
|
|
119
|
+
match event.key():
|
|
120
|
+
case Qt.Key.Key_Return | Qt.Key.Key_Enter:
|
|
121
|
+
self.add_chip()
|
|
122
|
+
return super().eventFilter(obj, event)
|
|
123
|
+
|
|
124
|
+
def get_unweight_chip(self, tag: str) -> str:
|
|
125
|
+
if self.weight_re and self.weight_re.search(tag):
|
|
126
|
+
return self.weight_re.search(tag).groups()[0]
|
|
127
|
+
return tag
|
|
128
|
+
|
|
129
|
+
def get_chip_list(self, tags: list[str]) -> list[str]:
|
|
130
|
+
return [self.get_unweight_chip(tag) for tag in tags]
|
|
131
|
+
|
|
132
|
+
def same_values(self, values: list[str]) -> bool:
|
|
133
|
+
return set(self.get_chip_list(values)) == set(
|
|
134
|
+
self.get_chip_list(self.values)
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
def handle_multiselect_change(self, selections: list[str] | str) -> None:
|
|
138
|
+
if isinstance(selections, str):
|
|
139
|
+
selections = [selections]
|
|
140
|
+
|
|
141
|
+
selections = utils.dedupe_list(selections)
|
|
142
|
+
if self.same_values(selections):
|
|
143
|
+
return
|
|
144
|
+
|
|
145
|
+
# self.values = selections
|
|
146
|
+
self.set_items(selections, debug=True)
|
|
147
|
+
|
|
148
|
+
def update_multiselect(self, values: list[str]) -> None:
|
|
149
|
+
if isinstance(self.list_widget, MultiSelectComboBox):
|
|
150
|
+
self.list_widget.set_selected(values, emit=False)
|
|
151
|
+
else:
|
|
152
|
+
value = values[0] if values else ""
|
|
153
|
+
self.list_widget.setCurrentText(value)
|
|
154
|
+
|
|
155
|
+
def clear_widgets(self) -> None:
|
|
156
|
+
while self.chip_layout.count():
|
|
157
|
+
widget = self.chip_layout.takeAt(0).widget()
|
|
158
|
+
if widget is not None:
|
|
159
|
+
widget.deleteLater()
|
|
160
|
+
|
|
161
|
+
def set_items(
|
|
162
|
+
self, values: list[str] = None, emit: bool = True, debug: bool = False
|
|
163
|
+
) -> None:
|
|
164
|
+
self.clear_widgets()
|
|
165
|
+
# TODO: Temp fix for weighted/custom tags and incoming multiselect values
|
|
166
|
+
if values:
|
|
167
|
+
tags = self.get_tag_list(self.values)
|
|
168
|
+
for value in values:
|
|
169
|
+
if value not in tags:
|
|
170
|
+
self.values.append(value)
|
|
171
|
+
|
|
172
|
+
self.values = utils.dedupe_list(self.values)
|
|
173
|
+
self.values = [value for value in self.values if value]
|
|
174
|
+
for item in self.values:
|
|
175
|
+
if not consts.GlobalRefs.debug_set:
|
|
176
|
+
debug = True
|
|
177
|
+
consts.GlobalRefs.debug_set = True
|
|
178
|
+
else:
|
|
179
|
+
debug = False
|
|
180
|
+
chip_button = ChipButton(
|
|
181
|
+
item,
|
|
182
|
+
self.update_chip,
|
|
183
|
+
self.remove_chip,
|
|
184
|
+
debug=debug,
|
|
185
|
+
weight_re=self.weight_re,
|
|
186
|
+
use_weight=self.use_weight,
|
|
187
|
+
)
|
|
188
|
+
""" chip_button.setToolTip(
|
|
189
|
+
f"Tag Count: {consts.TAG_COUNTS.get(item, "N/A")}"
|
|
190
|
+
) """
|
|
191
|
+
|
|
192
|
+
self.chip_layout.addWidget(chip_button)
|
|
193
|
+
|
|
194
|
+
if emit:
|
|
195
|
+
self.emit_changes()
|
|
196
|
+
|
|
197
|
+
def update_chip(self, prev_text: str, new_text: str) -> None:
|
|
198
|
+
idx = self.values.index(prev_text)
|
|
199
|
+
if idx >= 0:
|
|
200
|
+
self.values[idx] = new_text
|
|
201
|
+
|
|
202
|
+
def emit_changes(self):
|
|
203
|
+
self.valuesChanged.emit(self.values)
|
|
204
|
+
|
|
205
|
+
def add_chip(self):
|
|
206
|
+
text = self.input_field.text().strip()
|
|
207
|
+
if text and text not in self.values:
|
|
208
|
+
chip_button = ChipButton(text, self.update_chip, self.remove_chip)
|
|
209
|
+
|
|
210
|
+
self.chip_layout.addWidget(chip_button)
|
|
211
|
+
self.values.append(text)
|
|
212
|
+
self.emit_changes()
|
|
213
|
+
self.input_field.setText("")
|
|
214
|
+
|
|
215
|
+
def remove_chip(self, button: ChipButton):
|
|
216
|
+
self.chip_layout.removeWidget(button)
|
|
217
|
+
button.deleteLater() # Schedule button widget for deletion
|
|
218
|
+
self.values.remove(button.text)
|
|
219
|
+
self.emit_changes()
|
|
220
|
+
|
|
221
|
+
def add_chips(self, items: list[str]) -> None:
|
|
222
|
+
utils.update_list_values(self.values, items)
|
|
223
|
+
self.set_items()
|
|
224
|
+
|
|
225
|
+
def remove_chips(self, items: list[str]) -> None:
|
|
226
|
+
utils.remove_list_values(self.values, items)
|
|
227
|
+
self.set_items()
|
|
228
|
+
|
|
229
|
+
def remove_all(self, *_) -> None:
|
|
230
|
+
self.values = []
|
|
231
|
+
self.clear_widgets()
|
|
232
|
+
self.emit_changes()
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
from re import Pattern
|
|
2
|
+
|
|
3
|
+
from PyQt6.QtCore import QObject, pyqtSignal, Qt
|
|
4
|
+
from PyQt6.QtGui import QCursor, QMouseEvent, QWheelEvent
|
|
5
|
+
from PyQt6.QtWidgets import QComboBox, QListWidget, QListWidgetItem
|
|
6
|
+
|
|
7
|
+
import jb_utils as utils
|
|
8
|
+
from jbqt.common import consts, qt_utils
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class MultiSelectComboBox(QComboBox):
|
|
12
|
+
selectedChanged = pyqtSignal(list)
|
|
13
|
+
|
|
14
|
+
def __init__(
|
|
15
|
+
self,
|
|
16
|
+
data: list | dict = None,
|
|
17
|
+
selected: list[str] = None,
|
|
18
|
+
multi_enabled: bool = True,
|
|
19
|
+
use_weight: bool = False,
|
|
20
|
+
weight_re: Pattern = None,
|
|
21
|
+
):
|
|
22
|
+
super().__init__()
|
|
23
|
+
|
|
24
|
+
self.use_weight = use_weight
|
|
25
|
+
if use_weight:
|
|
26
|
+
weight_re = weight_re or consts.TAG_RE
|
|
27
|
+
|
|
28
|
+
self.weight_re = weight_re
|
|
29
|
+
|
|
30
|
+
self._multi_select_enabled: bool = multi_enabled
|
|
31
|
+
# Make the combo box read-only and use a custom view
|
|
32
|
+
self.setEditable(True)
|
|
33
|
+
self.lineEdit().setReadOnly(True)
|
|
34
|
+
# Override the mouse press event of the QLineEdit
|
|
35
|
+
self.lineEdit().installEventFilter(self)
|
|
36
|
+
|
|
37
|
+
self.view: QListWidget = QListWidget()
|
|
38
|
+
self._selected = selected or []
|
|
39
|
+
|
|
40
|
+
# Set the custom view
|
|
41
|
+
self.setModel(self.view.model())
|
|
42
|
+
self.setView(self.view)
|
|
43
|
+
|
|
44
|
+
# Add items and categories
|
|
45
|
+
if isinstance(data, dict):
|
|
46
|
+
for name, items in data.items():
|
|
47
|
+
self.add_group(name, items)
|
|
48
|
+
elif isinstance(data, list):
|
|
49
|
+
self.add_items(data)
|
|
50
|
+
|
|
51
|
+
# Connect the item clicked signal
|
|
52
|
+
self.view.itemClicked.connect(self.toggle_check_state)
|
|
53
|
+
self.update_display_text()
|
|
54
|
+
|
|
55
|
+
def eventFilter(self, obj: QObject, event: QMouseEvent) -> bool:
|
|
56
|
+
|
|
57
|
+
if (
|
|
58
|
+
obj == self.lineEdit()
|
|
59
|
+
and event.type() == QMouseEvent.Type.MouseButtonRelease
|
|
60
|
+
):
|
|
61
|
+
self.showPopup()
|
|
62
|
+
|
|
63
|
+
return True
|
|
64
|
+
return super().eventFilter(obj, event)
|
|
65
|
+
|
|
66
|
+
def wheelEvent(self, event: QWheelEvent):
|
|
67
|
+
# Check if the mouse cursor is within the bounds of the line edit
|
|
68
|
+
pos = self.mapFromGlobal(QCursor.pos())
|
|
69
|
+
if self.lineEdit().rect().contains(pos):
|
|
70
|
+
# Consume the wheel event to prevent scrolling
|
|
71
|
+
return
|
|
72
|
+
|
|
73
|
+
# If not over the line edit, let the base class handle it (scrolling)
|
|
74
|
+
super().wheelEvent(event)
|
|
75
|
+
|
|
76
|
+
def toggle_check_state(self, item: QListWidgetItem):
|
|
77
|
+
# Toggle check state if the item is selectable
|
|
78
|
+
if item.flags() & Qt.ItemFlag.ItemIsUserCheckable:
|
|
79
|
+
current_state = item.checkState()
|
|
80
|
+
|
|
81
|
+
new_state = (
|
|
82
|
+
Qt.CheckState.Unchecked
|
|
83
|
+
if current_state == Qt.CheckState.Checked
|
|
84
|
+
else Qt.CheckState.Checked
|
|
85
|
+
)
|
|
86
|
+
item.setCheckState(new_state)
|
|
87
|
+
if not self._multi_select_enabled:
|
|
88
|
+
self.toggle_others(item)
|
|
89
|
+
|
|
90
|
+
self.update_display_text()
|
|
91
|
+
self._selected_changed()
|
|
92
|
+
|
|
93
|
+
def toggle_others(self, selected: QListWidgetItem) -> None:
|
|
94
|
+
|
|
95
|
+
for i in range(self.view.count()):
|
|
96
|
+
item = self.view.item(i)
|
|
97
|
+
if item != selected:
|
|
98
|
+
item.setCheckState(Qt.CheckState.Unchecked)
|
|
99
|
+
self.update_display_text()
|
|
100
|
+
|
|
101
|
+
def setMultiSelectEnabled(self, value: bool) -> None:
|
|
102
|
+
self._multi_select_enabled = value
|
|
103
|
+
|
|
104
|
+
def multiSelectEnabled(self) -> bool:
|
|
105
|
+
return self._multi_select_enabled
|
|
106
|
+
|
|
107
|
+
def add_items(self, items: list[str] | dict, indent: int = 0) -> None:
|
|
108
|
+
|
|
109
|
+
indent_str = " " * indent
|
|
110
|
+
if isinstance(items, dict):
|
|
111
|
+
for name, children in items.items():
|
|
112
|
+
self.add_group(name, children, indent + 1)
|
|
113
|
+
else:
|
|
114
|
+
for item_value in items:
|
|
115
|
+
if isinstance(item_value, tuple):
|
|
116
|
+
item = QListWidgetItem(f"{indent_str}{item_value[0]}")
|
|
117
|
+
item.setData(consts.LIST_ITEM_ROLE, item_value[1])
|
|
118
|
+
|
|
119
|
+
else:
|
|
120
|
+
item = QListWidgetItem(f"{indent_str}{item_value}")
|
|
121
|
+
item.setFlags(
|
|
122
|
+
Qt.ItemFlag.ItemIsUserCheckable | Qt.ItemFlag.ItemIsEnabled
|
|
123
|
+
)
|
|
124
|
+
value = item.data(consts.LIST_ITEM_ROLE) or item.text()
|
|
125
|
+
if value.strip() in self._selected:
|
|
126
|
+
item.setCheckState(Qt.CheckState.Checked)
|
|
127
|
+
else:
|
|
128
|
+
item.setCheckState(Qt.CheckState.Unchecked)
|
|
129
|
+
self.view.addItem(item)
|
|
130
|
+
|
|
131
|
+
def add_group(self, group_name: str, items: list[str], indent: int = 0):
|
|
132
|
+
indent_str = " " * indent
|
|
133
|
+
# Add group header
|
|
134
|
+
header_item = QListWidgetItem(indent_str + group_name)
|
|
135
|
+
header_item.setFlags(Qt.ItemFlag.ItemIsEnabled)
|
|
136
|
+
header_item.setData(Qt.ItemDataRole.UserRole, False) # Non-selectable
|
|
137
|
+
self.view.addItem(header_item)
|
|
138
|
+
|
|
139
|
+
# Add items with checkboxes
|
|
140
|
+
self.add_items(items, indent + 1)
|
|
141
|
+
|
|
142
|
+
def update_display_text(self):
|
|
143
|
+
selected_items = []
|
|
144
|
+
for i in range(self.view.count()):
|
|
145
|
+
item = self.view.item(i)
|
|
146
|
+
if item.checkState() == Qt.CheckState.Checked:
|
|
147
|
+
selected_items.append(item.text().strip())
|
|
148
|
+
|
|
149
|
+
text = ", ".join(selected_items)
|
|
150
|
+
self.lineEdit().setText(text)
|
|
151
|
+
|
|
152
|
+
def showPopup(self):
|
|
153
|
+
# Override showPopup to update text when the dropdown is shown
|
|
154
|
+
self.update_display_text()
|
|
155
|
+
super().showPopup()
|
|
156
|
+
|
|
157
|
+
def hidePopup(self):
|
|
158
|
+
# Override hidePopup to update text when closing dropdown
|
|
159
|
+
self.update_display_text()
|
|
160
|
+
super().hidePopup()
|
|
161
|
+
|
|
162
|
+
def same_selected(self, values: list[str]) -> bool:
|
|
163
|
+
return set(values) == set(self._selected)
|
|
164
|
+
|
|
165
|
+
def get_selected(self) -> list[str]:
|
|
166
|
+
return [
|
|
167
|
+
qt_utils.get_item_value(item)
|
|
168
|
+
for item in self.view.findItems("", Qt.MatchFlag.MatchContains)
|
|
169
|
+
if item.checkState() == Qt.CheckState.Checked
|
|
170
|
+
]
|
|
171
|
+
|
|
172
|
+
def _selected_changed(self):
|
|
173
|
+
self.selectedChanged.emit(self.get_selected())
|
|
174
|
+
|
|
175
|
+
def get_unweight_chip(self, tag: str) -> str:
|
|
176
|
+
if self.weight_re and self.weight_re.search(tag):
|
|
177
|
+
return self.weight_re.search(tag).groups()[0]
|
|
178
|
+
return tag
|
|
179
|
+
|
|
180
|
+
def get_chip_list(self, tags: list[str]) -> list[str]:
|
|
181
|
+
return [self.get_unweight_chip(tag) for tag in tags]
|
|
182
|
+
|
|
183
|
+
def set_selected(self, values: list[str], emit: bool = True):
|
|
184
|
+
"""if self.same_selected(values):
|
|
185
|
+
return"""
|
|
186
|
+
|
|
187
|
+
values = self.get_chip_list(values)
|
|
188
|
+
# Set the selected items and emit the signal
|
|
189
|
+
self._selected = utils.dedupe_list(values)
|
|
190
|
+
# Update the checked state of items based on the new selection
|
|
191
|
+
for i in range(self.view.count()):
|
|
192
|
+
item = self.view.item(i)
|
|
193
|
+
|
|
194
|
+
item.setCheckState(
|
|
195
|
+
Qt.CheckState.Checked
|
|
196
|
+
if qt_utils.get_item_value(item) in values
|
|
197
|
+
else Qt.CheckState.Unchecked
|
|
198
|
+
)
|
|
199
|
+
self.update_display_text()
|
|
200
|
+
if emit:
|
|
201
|
+
self._selected_changed()
|