pygpt-net 2.6.67__py3-none-any.whl → 2.7.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.
- pygpt_net/CHANGELOG.txt +20 -0
- pygpt_net/__init__.py +3 -3
- pygpt_net/controller/assistant/assistant.py +13 -8
- pygpt_net/controller/assistant/batch.py +29 -15
- pygpt_net/controller/assistant/files.py +19 -14
- pygpt_net/controller/assistant/store.py +63 -41
- pygpt_net/controller/attachment/attachment.py +45 -35
- pygpt_net/controller/chat/attachment.py +50 -39
- pygpt_net/controller/config/field/dictionary.py +26 -14
- pygpt_net/controller/ctx/common.py +27 -17
- pygpt_net/controller/ctx/ctx.py +185 -101
- pygpt_net/controller/files/files.py +101 -41
- pygpt_net/controller/idx/indexer.py +87 -31
- pygpt_net/controller/kernel/kernel.py +13 -2
- pygpt_net/controller/mode/mode.py +3 -3
- pygpt_net/controller/model/editor.py +70 -15
- pygpt_net/controller/model/importer.py +153 -54
- pygpt_net/controller/painter/common.py +43 -11
- pygpt_net/controller/painter/painter.py +2 -2
- pygpt_net/controller/presets/experts.py +68 -15
- pygpt_net/controller/presets/presets.py +72 -36
- pygpt_net/controller/settings/profile.py +76 -35
- pygpt_net/controller/settings/workdir.py +70 -39
- pygpt_net/core/assistants/files.py +20 -18
- pygpt_net/core/filesystem/actions.py +111 -10
- pygpt_net/core/filesystem/filesystem.py +72 -1
- pygpt_net/core/filesystem/packer.py +161 -1
- pygpt_net/core/idx/idx.py +12 -11
- pygpt_net/core/idx/worker.py +13 -1
- pygpt_net/core/image/image.py +2 -2
- pygpt_net/core/models/models.py +4 -4
- pygpt_net/core/profile/profile.py +13 -3
- pygpt_net/core/video/video.py +2 -3
- pygpt_net/data/config/config.json +3 -3
- pygpt_net/data/config/models.json +3 -3
- pygpt_net/data/css/style.dark.css +45 -0
- pygpt_net/data/css/style.light.css +46 -0
- pygpt_net/data/locale/locale.de.ini +5 -1
- pygpt_net/data/locale/locale.en.ini +5 -1
- pygpt_net/data/locale/locale.es.ini +5 -1
- pygpt_net/data/locale/locale.fr.ini +5 -1
- pygpt_net/data/locale/locale.it.ini +5 -1
- pygpt_net/data/locale/locale.pl.ini +6 -2
- pygpt_net/data/locale/locale.uk.ini +5 -1
- pygpt_net/data/locale/locale.zh.ini +5 -1
- pygpt_net/provider/api/openai/__init__.py +4 -2
- pygpt_net/provider/core/config/patch.py +17 -1
- pygpt_net/tools/image_viewer/tool.py +17 -0
- pygpt_net/tools/text_editor/tool.py +9 -0
- pygpt_net/ui/__init__.py +2 -2
- pygpt_net/ui/dialog/preset.py +1 -0
- pygpt_net/ui/layout/ctx/ctx_list.py +16 -6
- pygpt_net/ui/layout/toolbox/image.py +2 -1
- pygpt_net/ui/layout/toolbox/indexes.py +2 -0
- pygpt_net/ui/layout/toolbox/video.py +5 -1
- pygpt_net/ui/main.py +3 -1
- pygpt_net/ui/widget/calendar/select.py +3 -3
- pygpt_net/ui/widget/draw/painter.py +238 -51
- pygpt_net/ui/widget/filesystem/explorer.py +1164 -142
- pygpt_net/ui/widget/lists/assistant.py +185 -24
- pygpt_net/ui/widget/lists/assistant_store.py +245 -42
- pygpt_net/ui/widget/lists/attachment.py +230 -47
- pygpt_net/ui/widget/lists/attachment_ctx.py +189 -33
- pygpt_net/ui/widget/lists/base_list_combo.py +2 -2
- pygpt_net/ui/widget/lists/context.py +1253 -70
- pygpt_net/ui/widget/lists/experts.py +110 -8
- pygpt_net/ui/widget/lists/model_editor.py +217 -14
- pygpt_net/ui/widget/lists/model_importer.py +125 -6
- pygpt_net/ui/widget/lists/preset.py +460 -71
- pygpt_net/ui/widget/lists/profile.py +149 -27
- pygpt_net/ui/widget/lists/uploaded.py +230 -38
- pygpt_net/ui/widget/option/combo.py +1211 -33
- pygpt_net/ui/widget/option/dictionary.py +35 -7
- {pygpt_net-2.6.67.dist-info → pygpt_net-2.7.1.dist-info}/METADATA +22 -57
- {pygpt_net-2.6.67.dist-info → pygpt_net-2.7.1.dist-info}/RECORD +78 -78
- {pygpt_net-2.6.67.dist-info → pygpt_net-2.7.1.dist-info}/LICENSE +0 -0
- {pygpt_net-2.6.67.dist-info → pygpt_net-2.7.1.dist-info}/WHEEL +0 -0
- {pygpt_net-2.6.67.dist-info → pygpt_net-2.7.1.dist-info}/entry_points.txt +0 -0
|
@@ -6,23 +6,43 @@
|
|
|
6
6
|
# GitHub: https://github.com/szczyglis-dev/py-gpt #
|
|
7
7
|
# MIT License #
|
|
8
8
|
# Created By : Marcin Szczygliński #
|
|
9
|
-
# Updated Date: 2025.
|
|
9
|
+
# Updated Date: 2025.12.28 00:00:00 #
|
|
10
10
|
# ================================================== #
|
|
11
11
|
|
|
12
|
-
from PySide6.QtCore import Qt
|
|
13
|
-
from PySide6.QtWidgets import
|
|
14
|
-
|
|
12
|
+
from PySide6.QtCore import Qt, QEvent, QTimer, QRect, Property
|
|
13
|
+
from PySide6.QtWidgets import (
|
|
14
|
+
QHBoxLayout,
|
|
15
|
+
QWidget,
|
|
16
|
+
QComboBox,
|
|
17
|
+
QAbstractItemView,
|
|
18
|
+
QStyle,
|
|
19
|
+
QStyleOptionComboBox,
|
|
20
|
+
QLineEdit,
|
|
21
|
+
QListView,
|
|
22
|
+
QStyledItemDelegate,
|
|
23
|
+
QStyleOptionViewItem,
|
|
24
|
+
)
|
|
25
|
+
from PySide6.QtGui import (
|
|
26
|
+
QFontMetrics,
|
|
27
|
+
QStandardItem,
|
|
28
|
+
QStandardItemModel,
|
|
29
|
+
QIcon, # keep existing imports, extend with items
|
|
30
|
+
QColor,
|
|
31
|
+
QPainter,
|
|
32
|
+
QPen,
|
|
33
|
+
QBrush,
|
|
34
|
+
QPalette,
|
|
35
|
+
)
|
|
15
36
|
|
|
16
37
|
from pygpt_net.utils import trans
|
|
17
38
|
|
|
39
|
+
|
|
18
40
|
class SeparatorComboBox(QComboBox):
|
|
19
41
|
"""A combo box that supports adding separator items and prevents selecting them."""
|
|
20
42
|
|
|
21
43
|
def __init__(self, parent=None):
|
|
22
44
|
super().__init__(parent)
|
|
23
|
-
# Custom role used to mark separator rows without interfering with existing UserRole data
|
|
24
45
|
self._SEP_ROLE = Qt.UserRole + 1000
|
|
25
|
-
# Internal guard to avoid recursive index changes
|
|
26
46
|
self._block_guard = False
|
|
27
47
|
|
|
28
48
|
def addSeparator(self, text):
|
|
@@ -35,25 +55,25 @@ class SeparatorComboBox(QComboBox):
|
|
|
35
55
|
model = self.model()
|
|
36
56
|
if isinstance(model, QStandardItemModel):
|
|
37
57
|
item = QStandardItem(text)
|
|
38
|
-
# Disable and make the row unselectable
|
|
39
58
|
item.setFlags(item.flags() & ~Qt.ItemIsEnabled & ~Qt.ItemIsSelectable)
|
|
40
|
-
# Mark explicitly as separator using custom role
|
|
41
59
|
item.setData(True, self._SEP_ROLE)
|
|
42
60
|
model.appendRow(item)
|
|
43
61
|
else:
|
|
44
|
-
# Fallback: keep previous behavior and additionally tag item with custom role
|
|
45
62
|
index = self.count()
|
|
46
63
|
self.addItem(text)
|
|
47
64
|
try:
|
|
48
65
|
role = Qt.UserRole - 1
|
|
49
|
-
self.setItemData(index, 0, role)
|
|
66
|
+
self.setItemData(index, 0, role)
|
|
50
67
|
except Exception:
|
|
51
68
|
pass
|
|
52
|
-
# Tag as separator via custom role for later checks
|
|
53
69
|
self.setItemData(index, True, self._SEP_ROLE)
|
|
54
70
|
|
|
55
71
|
def is_separator(self, index: int) -> bool:
|
|
56
|
-
"""
|
|
72
|
+
"""
|
|
73
|
+
Returns True if item at index is a separator.
|
|
74
|
+
|
|
75
|
+
:param index: The index to check.
|
|
76
|
+
"""
|
|
57
77
|
if index < 0 or index >= self.count():
|
|
58
78
|
return False
|
|
59
79
|
try:
|
|
@@ -61,7 +81,6 @@ class SeparatorComboBox(QComboBox):
|
|
|
61
81
|
return True
|
|
62
82
|
except Exception:
|
|
63
83
|
pass
|
|
64
|
-
# Fallback: check flags (works with item models)
|
|
65
84
|
try:
|
|
66
85
|
idx = self.model().index(index, self.modelColumn(), self.rootModelIndex())
|
|
67
86
|
flags = self.model().flags(idx)
|
|
@@ -78,14 +97,28 @@ class SeparatorComboBox(QComboBox):
|
|
|
78
97
|
return i
|
|
79
98
|
return -1
|
|
80
99
|
|
|
100
|
+
def last_valid_index(self) -> int:
|
|
101
|
+
"""
|
|
102
|
+
Returns the last non-separator index, or -1 if none.
|
|
103
|
+
|
|
104
|
+
:return: last valid index or -1
|
|
105
|
+
"""
|
|
106
|
+
for i in range(self.count() - 1, -1, -1):
|
|
107
|
+
if not self.is_separator(i):
|
|
108
|
+
return i
|
|
109
|
+
return -1
|
|
110
|
+
|
|
81
111
|
def _sanitize_index(self, index: int) -> int:
|
|
82
|
-
"""
|
|
112
|
+
"""
|
|
113
|
+
Returns a corrected non-separator index, or -1 if none available.
|
|
114
|
+
|
|
115
|
+
:param index: The index to sanitize.
|
|
116
|
+
"""
|
|
83
117
|
if index is None:
|
|
84
118
|
index = -1
|
|
85
119
|
if index < 0 or index >= self.count():
|
|
86
120
|
return self.first_valid_index()
|
|
87
121
|
if self.is_separator(index):
|
|
88
|
-
# Prefer the next valid item; if none, scan backwards; else -1
|
|
89
122
|
for i in range(index + 1, self.count()):
|
|
90
123
|
if not self.is_separator(i):
|
|
91
124
|
return i
|
|
@@ -99,6 +132,8 @@ class SeparatorComboBox(QComboBox):
|
|
|
99
132
|
"""
|
|
100
133
|
Ensures the current index is not a separator.
|
|
101
134
|
Returns the final valid index (or -1) after correction.
|
|
135
|
+
|
|
136
|
+
:return: valid current index or -1
|
|
102
137
|
"""
|
|
103
138
|
current = super().currentIndex()
|
|
104
139
|
corrected = self._sanitize_index(current)
|
|
@@ -113,9 +148,10 @@ class SeparatorComboBox(QComboBox):
|
|
|
113
148
|
def setCurrentIndex(self, index: int) -> None:
|
|
114
149
|
"""
|
|
115
150
|
Prevent setting the current index to a separator from any caller.
|
|
151
|
+
|
|
152
|
+
:param index: The desired index to set.
|
|
116
153
|
"""
|
|
117
154
|
if self._block_guard:
|
|
118
|
-
# When guarded, pass through without checks to avoid recursion
|
|
119
155
|
return super().setCurrentIndex(index)
|
|
120
156
|
corrected = self._sanitize_index(index)
|
|
121
157
|
try:
|
|
@@ -125,16 +161,1148 @@ class SeparatorComboBox(QComboBox):
|
|
|
125
161
|
self._block_guard = False
|
|
126
162
|
|
|
127
163
|
|
|
128
|
-
|
|
129
|
-
|
|
164
|
+
# ----- Popup list view and delegate to support styling of the currently selected item -----
|
|
165
|
+
|
|
166
|
+
class ComboPopupListView(QListView):
|
|
167
|
+
"""
|
|
168
|
+
QListView used as QComboBox popup, extended with:
|
|
169
|
+
- Style-probe child widget with class 'current-selected' to allow QSS-driven colors/fonts for the 'current-selected' item.
|
|
170
|
+
- Q_PROPERTIES to allow QSS set explicit parameters (bg/fg/bold/left stripe).
|
|
171
|
+
"""
|
|
172
|
+
|
|
173
|
+
def __init__(self, owner_combo: QComboBox, parent=None):
|
|
174
|
+
super().__init__(parent or owner_combo)
|
|
175
|
+
self._owner_combo = owner_combo
|
|
176
|
+
|
|
177
|
+
# Stable identifiers for styling
|
|
178
|
+
self.setObjectName("ComboPopupList")
|
|
179
|
+
self.viewport().setObjectName("ComboPopupViewport")
|
|
180
|
+
self.setUniformItemSizes(False)
|
|
181
|
+
self.setProperty("class", "combo-popup")
|
|
182
|
+
# Expose owner class for QSS filtering, e.g.: QAbstractItemView[comboClass="NoScrollCombo"]
|
|
183
|
+
try:
|
|
184
|
+
self.setProperty("comboClass", type(owner_combo).__name__)
|
|
185
|
+
except Exception:
|
|
186
|
+
pass
|
|
187
|
+
|
|
188
|
+
# Style probe used to fetch palette/font configured via QSS for '.current-selected'
|
|
189
|
+
self._current_style_probe = QWidget(self)
|
|
190
|
+
self._current_style_probe.setObjectName("current-selected") # optional id target
|
|
191
|
+
self._current_style_probe.setProperty("class", "current-selected")
|
|
192
|
+
self._current_style_probe.setVisible(False)
|
|
193
|
+
self._current_style_probe.setAttribute(Qt.WA_TransparentForMouseEvents, True)
|
|
194
|
+
self._current_style_probe.setFocusPolicy(Qt.NoFocus)
|
|
195
|
+
|
|
196
|
+
# Defaults for properties (can be overridden via QSS qproperty-*)
|
|
197
|
+
self._cs_bg = QColor() # null = do not override
|
|
198
|
+
self._cs_fg = QColor() # null = do not override
|
|
199
|
+
self._cs_bold = False
|
|
200
|
+
self._cs_left_w = 0
|
|
201
|
+
self._cs_left_color = QColor()
|
|
202
|
+
|
|
203
|
+
# Q_PROPERTY: currentSelectedBgColor
|
|
204
|
+
def _get_cs_bg(self):
|
|
205
|
+
return self._cs_bg
|
|
206
|
+
|
|
207
|
+
def _set_cs_bg(self, val):
|
|
208
|
+
self._cs_bg = QColor(val) if not isinstance(val, QColor) else val
|
|
209
|
+
self.viewport().update()
|
|
210
|
+
|
|
211
|
+
currentSelectedBgColor = Property(QColor, _get_cs_bg, _set_cs_bg)
|
|
212
|
+
|
|
213
|
+
# Q_PROPERTY: currentSelectedTextColor
|
|
214
|
+
def _get_cs_fg(self):
|
|
215
|
+
return self._cs_fg
|
|
216
|
+
|
|
217
|
+
def _set_cs_fg(self, val):
|
|
218
|
+
self._cs_fg = QColor(val) if not isinstance(val, QColor) else val
|
|
219
|
+
self.viewport().update()
|
|
220
|
+
|
|
221
|
+
currentSelectedTextColor = Property(QColor, _get_cs_fg, _set_cs_fg)
|
|
222
|
+
|
|
223
|
+
# Q_PROPERTY: currentSelectedBold
|
|
224
|
+
def _get_cs_bold(self):
|
|
225
|
+
return self._cs_bold
|
|
226
|
+
|
|
227
|
+
def _set_cs_bold(self, val):
|
|
228
|
+
self._cs_bold = bool(val)
|
|
229
|
+
self.viewport().update()
|
|
230
|
+
|
|
231
|
+
currentSelectedBold = Property(bool, _get_cs_bold, _set_cs_bold)
|
|
232
|
+
|
|
233
|
+
# Q_PROPERTY: currentSelectedLeftStripeWidth
|
|
234
|
+
def _get_cs_left_w(self):
|
|
235
|
+
return self._cs_left_w
|
|
236
|
+
|
|
237
|
+
def _set_cs_left_w(self, val):
|
|
238
|
+
try:
|
|
239
|
+
self._cs_left_w = max(0, int(val))
|
|
240
|
+
except Exception:
|
|
241
|
+
self._cs_left_w = 0
|
|
242
|
+
self.viewport().update()
|
|
243
|
+
|
|
244
|
+
currentSelectedLeftStripeWidth = Property(int, _get_cs_left_w, _set_cs_left_w)
|
|
245
|
+
|
|
246
|
+
# Q_PROPERTY: currentSelectedLeftStripeColor
|
|
247
|
+
def _get_cs_left_color(self):
|
|
248
|
+
return self._cs_left_color
|
|
249
|
+
|
|
250
|
+
def _set_cs_left_color(self, val):
|
|
251
|
+
self._cs_left_color = QColor(val) if not isinstance(val, QColor) else val
|
|
252
|
+
self.viewport().update()
|
|
253
|
+
|
|
254
|
+
currentSelectedLeftStripeColor = Property(QColor, _get_cs_left_color, _set_cs_left_color)
|
|
255
|
+
|
|
256
|
+
# Helpers to resolve effective style for the current-selected mark
|
|
257
|
+
def _is_valid_color(self, c: QColor) -> bool:
|
|
258
|
+
try:
|
|
259
|
+
return isinstance(c, QColor) and c.isValid() and c.alpha() > 0
|
|
260
|
+
except Exception:
|
|
261
|
+
return False
|
|
262
|
+
|
|
263
|
+
def current_selected_style(self):
|
|
264
|
+
"""
|
|
265
|
+
Resolve effective style values for the 'current-selected' mark from:
|
|
266
|
+
1) qproperties set on this view (highest priority)
|
|
267
|
+
2) style-probe palette/font (QSS: [class~="current-selected"])
|
|
268
|
+
3) palette fallback
|
|
269
|
+
"""
|
|
270
|
+
try:
|
|
271
|
+
probe = self._current_style_probe
|
|
272
|
+
probe.ensurePolished()
|
|
273
|
+
except Exception:
|
|
274
|
+
probe = None
|
|
275
|
+
|
|
276
|
+
# Defaults from palette
|
|
277
|
+
default_fg = self.palette().color(QPalette.Text)
|
|
278
|
+
default_bg = QColor(0, 0, 0, 0)
|
|
279
|
+
default_stripe = self.palette().color(QPalette.Highlight)
|
|
280
|
+
|
|
281
|
+
fg = self._cs_fg if self._is_valid_color(self._cs_fg) else (
|
|
282
|
+
probe.palette().color(QPalette.Text) if probe is not None else default_fg
|
|
283
|
+
)
|
|
284
|
+
bg = self._cs_bg if self._is_valid_color(self._cs_bg) else (
|
|
285
|
+
probe.palette().color(QPalette.Base) if probe is not None else default_bg
|
|
286
|
+
)
|
|
287
|
+
# If Base is fully transparent for QWidget probe, try Window
|
|
288
|
+
if not self._is_valid_color(bg) and probe is not None:
|
|
289
|
+
alt = probe.palette().color(QPalette.Window)
|
|
290
|
+
if self._is_valid_color(alt):
|
|
291
|
+
bg = alt
|
|
292
|
+
|
|
293
|
+
stripe_w = self._cs_left_w
|
|
294
|
+
stripe_color = self._cs_left_color if self._is_valid_color(self._cs_left_color) else (
|
|
295
|
+
probe.palette().color(QPalette.Highlight) if probe is not None else default_stripe
|
|
296
|
+
)
|
|
297
|
+
bold = self._cs_bold or (probe.font().bold() if probe is not None else False)
|
|
298
|
+
|
|
299
|
+
return fg, bg, bold, stripe_w, stripe_color
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
class CurrentSelectedDelegate(QStyledItemDelegate):
|
|
303
|
+
"""
|
|
304
|
+
Item delegate that draws a subtle, QSS-controlled mark for the combo's currently selected item.
|
|
305
|
+
It does not interfere with normal :selected or :hover visuals drawn by the base delegate.
|
|
306
|
+
"""
|
|
307
|
+
|
|
308
|
+
def __init__(self, combo_owner: 'SearchableCombo', parent=None):
|
|
309
|
+
super().__init__(parent or combo_owner)
|
|
310
|
+
self._combo = combo_owner
|
|
311
|
+
|
|
312
|
+
def paint(self, painter: QPainter, option, index):
|
|
313
|
+
# Skip separators
|
|
314
|
+
try:
|
|
315
|
+
if self._combo.is_separator(index.row()):
|
|
316
|
+
return super().paint(painter, option, index)
|
|
317
|
+
except Exception:
|
|
318
|
+
pass
|
|
319
|
+
|
|
320
|
+
is_current_combo = False
|
|
321
|
+
try:
|
|
322
|
+
is_current_combo = (index.row() == self._combo.currentIndex())
|
|
323
|
+
except Exception:
|
|
324
|
+
is_current_combo = False
|
|
325
|
+
|
|
326
|
+
view = self._combo.view()
|
|
327
|
+
fg, bg, bold, stripe_w, stripe_color = (None, None, False, 0, None)
|
|
328
|
+
if isinstance(view, ComboPopupListView):
|
|
329
|
+
fg, bg, bold, stripe_w, stripe_color = view.current_selected_style()
|
|
330
|
+
|
|
331
|
+
# Prepare option clone to adjust font/colors when item is the current combo value
|
|
332
|
+
opt = QStyleOptionViewItem(option)
|
|
333
|
+
selected = bool(opt.state & QStyle.State_Selected)
|
|
334
|
+
hovered = bool(opt.state & QStyle.State_MouseOver)
|
|
335
|
+
|
|
336
|
+
if is_current_combo and not selected:
|
|
337
|
+
# Apply text color and bold font only when not in selected state to not clash with :selected visuals
|
|
338
|
+
if isinstance(fg, QColor) and fg.isValid():
|
|
339
|
+
opt.palette.setColor(QPalette.Text, fg)
|
|
340
|
+
opt.palette.setColor(QPalette.HighlightedText, fg)
|
|
341
|
+
if bold:
|
|
342
|
+
opt.font.setBold(True)
|
|
343
|
+
|
|
344
|
+
# Fill background before default painting when applicable and not selected/hovered
|
|
345
|
+
if is_current_combo and not selected and not hovered:
|
|
346
|
+
if isinstance(bg, QColor) and bg.isValid() and bg.alpha() > 0:
|
|
347
|
+
painter.save()
|
|
348
|
+
painter.setBrush(QBrush(bg))
|
|
349
|
+
painter.setPen(Qt.NoPen)
|
|
350
|
+
painter.drawRect(opt.rect)
|
|
351
|
+
painter.restore()
|
|
352
|
+
|
|
353
|
+
# Default painting (respects QSS for :selected and :hover)
|
|
354
|
+
super().paint(painter, opt, index)
|
|
355
|
+
|
|
356
|
+
# Draw left stripe/marker overlay to persistently mark current selection even when hovered/selected
|
|
357
|
+
if is_current_combo and isinstance(stripe_color, QColor) and stripe_w and stripe_w > 0:
|
|
358
|
+
painter.save()
|
|
359
|
+
pen = QPen(stripe_color)
|
|
360
|
+
pen.setWidth(1)
|
|
361
|
+
painter.setPen(Qt.NoPen)
|
|
362
|
+
painter.setBrush(QBrush(stripe_color))
|
|
363
|
+
r = opt.rect.adjusted(1, 1, 0, -1)
|
|
364
|
+
stripe_rect = QRect(r.x(), r.y(), min(stripe_w, max(1, r.width() // 6)), r.height())
|
|
365
|
+
painter.drawRect(stripe_rect)
|
|
366
|
+
painter.restore()
|
|
367
|
+
|
|
368
|
+
|
|
369
|
+
class SearchableCombo(SeparatorComboBox):
|
|
370
|
+
"""
|
|
371
|
+
A combo box with web-like search input shown while the popup is open.
|
|
372
|
+
|
|
373
|
+
Behaviour:
|
|
374
|
+
- search enabled by default (self.search == True).
|
|
375
|
+
- First click on the combo opens the popup and displays a search field with a magnifier icon at the top of the popup,
|
|
376
|
+
visually overlapping the combo area. The search field receives focus immediately (caret at end).
|
|
377
|
+
- Typing scrolls the dropdown so that the first match appears at the top; nothing is auto-selected.
|
|
378
|
+
- Popup is closed by focus out (clicking outside).
|
|
379
|
+
"""
|
|
380
|
+
def __init__(self, parent=None):
|
|
381
|
+
super().__init__(parent)
|
|
382
|
+
self.search: bool = True
|
|
383
|
+
self._popup_open: bool = False
|
|
384
|
+
self._search_line: QLineEdit | None = None
|
|
385
|
+
self._search_action = None
|
|
386
|
+
self._search_icon_path: str = ":/icons/search.svg"
|
|
387
|
+
|
|
388
|
+
self._popup_container = None
|
|
389
|
+
self._popup_header: QLineEdit | None = None
|
|
390
|
+
self._popup_header_action = None
|
|
391
|
+
self._popup_header_h: int = 28
|
|
392
|
+
|
|
393
|
+
self._last_query_text: str = ""
|
|
394
|
+
self._suppress_search: bool = False
|
|
395
|
+
|
|
396
|
+
# Guard flags for mouse handling
|
|
397
|
+
self._swallow_release_once: bool = False # kept for compatibility; not used in the new flow
|
|
398
|
+
self._open_on_release: bool = False # open popup on mouse release (non-arrow path)
|
|
399
|
+
|
|
400
|
+
# Popup fitting helpers
|
|
401
|
+
self._fit_in_progress: bool = False
|
|
402
|
+
self._popup_parent_window = None
|
|
403
|
+
self._popup_right_margin_px: int = 4 # small safety margin from window right edge
|
|
404
|
+
|
|
405
|
+
self._install_persistent_editor()
|
|
406
|
+
self._init_popup_view_style_targets()
|
|
407
|
+
|
|
408
|
+
# Keep popup visuals in sync when current index changes
|
|
409
|
+
try:
|
|
410
|
+
self.currentIndexChanged.connect(self._refresh_popup_view)
|
|
411
|
+
except Exception:
|
|
412
|
+
pass
|
|
413
|
+
|
|
414
|
+
# ----- Make the popup list reliably stylable -----
|
|
415
|
+
|
|
416
|
+
def _init_popup_view_style_targets(self):
|
|
417
|
+
"""
|
|
418
|
+
Ensure the popup list can be styled by common QSS rules:
|
|
419
|
+
- Use a QListView explicitly.
|
|
420
|
+
- Install a QStyledItemDelegate so sub-control item rules can take effect.
|
|
421
|
+
- Provide stable objectNames/properties that themes (e.g., Qt Material) can target, if they rely on them.
|
|
422
|
+
- Extend with a custom delegate that allows styling the 'current-selected' row via QSS.
|
|
423
|
+
"""
|
|
424
|
+
try:
|
|
425
|
+
lv = ComboPopupListView(self, self)
|
|
426
|
+
lv.setUniformItemSizes(False)
|
|
427
|
+
self.setView(lv)
|
|
428
|
+
except Exception:
|
|
429
|
+
lv = None
|
|
430
|
+
|
|
431
|
+
try:
|
|
432
|
+
# Delegate that honors default QSS for items and adds 'current-selected' mark
|
|
433
|
+
self.setItemDelegate(CurrentSelectedDelegate(self, self))
|
|
434
|
+
except Exception:
|
|
435
|
+
try:
|
|
436
|
+
self.setItemDelegate(QStyledItemDelegate(self))
|
|
437
|
+
except Exception:
|
|
438
|
+
pass
|
|
439
|
+
|
|
440
|
+
if lv is not None:
|
|
441
|
+
try:
|
|
442
|
+
lv.setObjectName("ComboPopupList") # e.g.: QListView#ComboPopupList { ... }
|
|
443
|
+
lv.viewport().setObjectName("ComboPopupViewport")
|
|
444
|
+
except Exception:
|
|
445
|
+
pass
|
|
446
|
+
|
|
447
|
+
try:
|
|
448
|
+
# Some themes use class selectors; expose a generic one on the view and owner class name.
|
|
449
|
+
lv.setProperty("class", "combo-popup")
|
|
450
|
+
lv.setProperty("comboClass", type(self).__name__)
|
|
451
|
+
except Exception:
|
|
452
|
+
pass
|
|
453
|
+
|
|
454
|
+
# ----- Persistent editor (display only, outside the popup) -----
|
|
455
|
+
|
|
456
|
+
def _install_persistent_editor(self):
|
|
457
|
+
"""Create a persistent editor used for normal display; real search input lives in the popup header."""
|
|
458
|
+
self.setEditable(True)
|
|
459
|
+
line = QLineEdit(self)
|
|
460
|
+
line.setPlaceholderText("")
|
|
461
|
+
line.setClearButtonEnabled(False)
|
|
462
|
+
line.setReadOnly(True)
|
|
463
|
+
line.setFocusPolicy(Qt.NoFocus)
|
|
464
|
+
line.setAttribute(Qt.WA_TransparentForMouseEvents, True)
|
|
465
|
+
self.setLineEdit(line)
|
|
466
|
+
line.installEventFilter(self)
|
|
467
|
+
self._search_line = line
|
|
468
|
+
self._sync_editor_to_current()
|
|
469
|
+
|
|
470
|
+
# ----- Public API -----
|
|
471
|
+
|
|
472
|
+
def setSearchEnabled(self, enabled: bool):
|
|
473
|
+
"""
|
|
474
|
+
Enable or disable search functionality.
|
|
475
|
+
|
|
476
|
+
:param enabled: bool
|
|
477
|
+
"""
|
|
478
|
+
self.search = bool(enabled)
|
|
479
|
+
if not self.search:
|
|
480
|
+
self._teardown_popup_header()
|
|
481
|
+
if self._search_line is not None:
|
|
482
|
+
self._remove_magnifier_on(self._search_line)
|
|
483
|
+
self._search_line.setClearButtonEnabled(False)
|
|
484
|
+
self._search_line.setReadOnly(True)
|
|
485
|
+
self._search_line.setFocusPolicy(Qt.NoFocus)
|
|
486
|
+
self._sync_editor_to_current()
|
|
487
|
+
|
|
488
|
+
# ----- Popup lifecycle -----
|
|
489
|
+
|
|
490
|
+
def showPopup(self):
|
|
491
|
+
"""Open popup, set max visible height, inject header, and place it over the combo area."""
|
|
492
|
+
self._apply_popup_max_rows()
|
|
493
|
+
super().showPopup()
|
|
494
|
+
self._popup_open = True
|
|
495
|
+
|
|
496
|
+
first = self.first_valid_index()
|
|
497
|
+
if first != -1:
|
|
498
|
+
self._scroll_to_row(first)
|
|
499
|
+
|
|
500
|
+
if self.search:
|
|
501
|
+
self._prepare_popup_header()
|
|
502
|
+
|
|
503
|
+
# Ensure geometry fits horizontally within window bounds
|
|
504
|
+
self._fit_popup_to_window()
|
|
505
|
+
QTimer.singleShot(0, self._apply_popup_max_rows)
|
|
506
|
+
QTimer.singleShot(0, self._fit_popup_to_window)
|
|
507
|
+
self._refresh_popup_view()
|
|
508
|
+
|
|
509
|
+
def hidePopup(self):
|
|
510
|
+
"""Close popup and restore normal display text; remove header/margins."""
|
|
511
|
+
super().hidePopup()
|
|
512
|
+
self._popup_open = False
|
|
513
|
+
self._swallow_release_once = False # ensure release guard is cleared when popup closes
|
|
514
|
+
self._open_on_release = False
|
|
515
|
+
|
|
516
|
+
if self._popup_header is not None:
|
|
517
|
+
try:
|
|
518
|
+
t = (self._popup_header.text() or "").strip()
|
|
519
|
+
if t:
|
|
520
|
+
row = self._find_target_row_for(t.lower())
|
|
521
|
+
if row != -1:
|
|
522
|
+
self._last_query_text = t
|
|
523
|
+
else:
|
|
524
|
+
self._last_query_text = ""
|
|
525
|
+
except Exception:
|
|
526
|
+
pass
|
|
527
|
+
|
|
528
|
+
if self._search_line is not None:
|
|
529
|
+
self._remove_magnifier_on(self._search_line)
|
|
530
|
+
self._search_line.setClearButtonEnabled(False)
|
|
531
|
+
self._search_line.setReadOnly(True)
|
|
532
|
+
self._search_line.setFocusPolicy(Qt.NoFocus)
|
|
533
|
+
self._sync_editor_to_current()
|
|
534
|
+
|
|
535
|
+
self._teardown_popup_header()
|
|
536
|
+
|
|
537
|
+
# ----- Popup header management (search input inside popup) -----
|
|
538
|
+
|
|
539
|
+
def _prepare_popup_header(self):
|
|
540
|
+
"""
|
|
541
|
+
Create and place a search line edit inside the popup container itself.
|
|
542
|
+
The container is moved upwards by the header height so the header overlaps the combo area.
|
|
543
|
+
"""
|
|
544
|
+
view = self.view()
|
|
545
|
+
if view is None:
|
|
546
|
+
return
|
|
547
|
+
|
|
548
|
+
container = view.window()
|
|
549
|
+
if container is None:
|
|
550
|
+
return
|
|
551
|
+
|
|
552
|
+
# Expose recognizable identifiers on the popup container for stylesheet authors
|
|
553
|
+
try:
|
|
554
|
+
container.setObjectName("ComboPopupWindow") # QWidget#ComboPopupWindow { ... }
|
|
555
|
+
container.setProperty("class", "combo-popup-window")
|
|
556
|
+
container.setAttribute(Qt.WA_NoMouseReplay, True) # prevent unwanted mouse replays on popup show
|
|
557
|
+
except Exception:
|
|
558
|
+
pass
|
|
559
|
+
|
|
560
|
+
self._popup_container = container
|
|
561
|
+
container.installEventFilter(self)
|
|
562
|
+
|
|
563
|
+
# Track parent window moves/resizes while popup is open
|
|
564
|
+
try:
|
|
565
|
+
top = self.window()
|
|
566
|
+
if top is not None:
|
|
567
|
+
top.installEventFilter(self)
|
|
568
|
+
self._popup_parent_window = top
|
|
569
|
+
except Exception:
|
|
570
|
+
self._popup_parent_window = None
|
|
571
|
+
|
|
572
|
+
if self._popup_header is None:
|
|
573
|
+
self._popup_header = QLineEdit(container)
|
|
574
|
+
self._popup_header.setObjectName("comboSearchHeader")
|
|
575
|
+
self._popup_header.setClearButtonEnabled(True)
|
|
576
|
+
self._popup_header.setReadOnly(False)
|
|
577
|
+
self._popup_header.setFocusPolicy(Qt.ClickFocus)
|
|
578
|
+
self._popup_header.textChanged.connect(self._on_search_text_changed)
|
|
579
|
+
self._popup_header.installEventFilter(self)
|
|
580
|
+
self._ensure_magnifier_on(self._popup_header)
|
|
581
|
+
|
|
582
|
+
self._popup_header_h = max(24, self._popup_header.sizeHint().height())
|
|
583
|
+
|
|
584
|
+
try:
|
|
585
|
+
geo: QRect = container.geometry()
|
|
586
|
+
new_geo = QRect(geo.x(), geo.y() - self._popup_header_h, geo.width(), geo.height() + self._popup_header_h)
|
|
587
|
+
container.setGeometry(new_geo)
|
|
588
|
+
except Exception:
|
|
589
|
+
pass
|
|
590
|
+
|
|
591
|
+
try:
|
|
592
|
+
view.setViewportMargins(0, self._popup_header_h, 0, 0)
|
|
593
|
+
except Exception:
|
|
594
|
+
pass
|
|
595
|
+
|
|
596
|
+
try:
|
|
597
|
+
view.installEventFilter(self)
|
|
598
|
+
if hasattr(view, "viewport"):
|
|
599
|
+
view.viewport().installEventFilter(self)
|
|
600
|
+
except Exception:
|
|
601
|
+
pass
|
|
602
|
+
|
|
603
|
+
self._place_popup_header()
|
|
604
|
+
|
|
605
|
+
if self._search_line is not None:
|
|
606
|
+
self._search_line.setReadOnly(True)
|
|
607
|
+
self._search_line.setFocusPolicy(Qt.NoFocus)
|
|
608
|
+
self._ensure_magnifier_on(self._search_line)
|
|
609
|
+
|
|
610
|
+
self._prefill_with_current_or_restore_last()
|
|
611
|
+
self._focus_header_async()
|
|
612
|
+
|
|
613
|
+
def _place_popup_header(self):
|
|
614
|
+
"""Position the header inside popup container and keep it visible."""
|
|
615
|
+
if not self._popup_container or not self._popup_header:
|
|
616
|
+
return
|
|
617
|
+
try:
|
|
618
|
+
w = self._popup_container.width()
|
|
619
|
+
h = self._popup_header_h
|
|
620
|
+
self._popup_header.setGeometry(1, 1, max(1, w - 2), h - 2)
|
|
621
|
+
self._popup_header.show()
|
|
622
|
+
except Exception:
|
|
623
|
+
pass
|
|
624
|
+
|
|
625
|
+
def _teardown_popup_header(self):
|
|
626
|
+
"""Remove margins and hide the header, leaving the container to default."""
|
|
627
|
+
view = self.view()
|
|
628
|
+
if view is not None:
|
|
629
|
+
try:
|
|
630
|
+
view.setViewportMargins(0, 0, 0, 0)
|
|
631
|
+
except Exception:
|
|
632
|
+
pass
|
|
633
|
+
try:
|
|
634
|
+
view.removeEventFilter(self)
|
|
635
|
+
if hasattr(view, "viewport"):
|
|
636
|
+
view.viewport().removeEventFilter(self)
|
|
637
|
+
except Exception:
|
|
638
|
+
pass
|
|
639
|
+
if self._popup_header is not None:
|
|
640
|
+
self._popup_header.hide()
|
|
641
|
+
if self._popup_container is not None:
|
|
642
|
+
try:
|
|
643
|
+
self._popup_container.removeEventFilter(self)
|
|
644
|
+
except Exception:
|
|
645
|
+
pass
|
|
646
|
+
# Unhook parent window filter if any
|
|
647
|
+
if self._popup_parent_window is not None:
|
|
648
|
+
try:
|
|
649
|
+
self._popup_parent_window.removeEventFilter(self)
|
|
650
|
+
except Exception:
|
|
651
|
+
pass
|
|
652
|
+
self._popup_container = None
|
|
653
|
+
self._popup_parent_window = None
|
|
654
|
+
|
|
655
|
+
# ----- Mouse handling on combo (display area) -----
|
|
656
|
+
|
|
657
|
+
def _edit_field_rect(self):
|
|
658
|
+
"""Estimate the rectangle of the editable field area in the combo."""
|
|
659
|
+
opt = QStyleOptionComboBox()
|
|
660
|
+
self.initStyleOption(opt)
|
|
661
|
+
return self.style().subControlRect(QStyle.CC_ComboBox, opt, QStyle.SC_ComboBoxEditField, self)
|
|
662
|
+
|
|
663
|
+
def _arrow_rect(self):
|
|
664
|
+
"""Estimate the rectangle of the arrow area in the combo."""
|
|
665
|
+
opt = QStyleOptionComboBox()
|
|
666
|
+
self.initStyleOption(opt)
|
|
667
|
+
return self.style().subControlRect(QStyle.CC_ComboBox, opt, QStyle.SC_ComboBoxArrow, self)
|
|
668
|
+
|
|
669
|
+
def mousePressEvent(self, event):
|
|
670
|
+
"""
|
|
671
|
+
Use release-to-open on the non-arrow area to avoid immediate close when the popup opens upward.
|
|
672
|
+
Keep the arrow area with the default toggle behaviour from the base class.
|
|
673
|
+
"""
|
|
674
|
+
if event.button() == Qt.LeftButton and self.isEnabled():
|
|
675
|
+
arrow_rect = self._arrow_rect()
|
|
676
|
+
if arrow_rect.contains(event.pos()):
|
|
677
|
+
# Arrow path: keep default toggle semantics
|
|
678
|
+
self._open_on_release = False
|
|
679
|
+
self._swallow_release_once = False
|
|
680
|
+
return super().mousePressEvent(event)
|
|
681
|
+
|
|
682
|
+
# Non-arrow path
|
|
683
|
+
if self._popup_open:
|
|
684
|
+
# Toggle close if already open
|
|
685
|
+
self.hidePopup()
|
|
686
|
+
self._open_on_release = False
|
|
687
|
+
self._swallow_release_once = False
|
|
688
|
+
event.accept()
|
|
689
|
+
return
|
|
690
|
+
|
|
691
|
+
# Defer opening until mouse release to prevent instant close when popup is above
|
|
692
|
+
self._open_on_release = True
|
|
693
|
+
self._swallow_release_once = False
|
|
694
|
+
event.accept()
|
|
695
|
+
return
|
|
696
|
+
|
|
697
|
+
super().mousePressEvent(event)
|
|
698
|
+
|
|
699
|
+
def mouseReleaseEvent(self, event):
|
|
700
|
+
"""
|
|
701
|
+
Open the popup on release if the press started on the non-arrow area.
|
|
702
|
+
This avoids the popup being created mid-click (which can close immediately when opening upward).
|
|
703
|
+
"""
|
|
704
|
+
if event.button() == Qt.LeftButton and self._open_on_release:
|
|
705
|
+
self._open_on_release = False
|
|
706
|
+
if self.isEnabled() and not self._popup_open and self.rect().contains(event.pos()):
|
|
707
|
+
self.showPopup()
|
|
708
|
+
event.accept()
|
|
709
|
+
return
|
|
710
|
+
|
|
711
|
+
super().mouseReleaseEvent(event)
|
|
712
|
+
|
|
713
|
+
def keyPressEvent(self, event):
|
|
714
|
+
"""
|
|
715
|
+
Commit the highlighted item with Enter/Return while the popup is open.
|
|
716
|
+
"""
|
|
717
|
+
if self._popup_open:
|
|
718
|
+
if event.key() in (Qt.Key_Return, Qt.Key_Enter):
|
|
719
|
+
self._commit_view_current()
|
|
720
|
+
event.accept()
|
|
721
|
+
return
|
|
722
|
+
if event.key() == Qt.Key_Escape:
|
|
723
|
+
event.accept()
|
|
724
|
+
return
|
|
725
|
+
super().keyPressEvent(event)
|
|
726
|
+
|
|
727
|
+
# ----- Event filter -----
|
|
728
|
+
|
|
729
|
+
def eventFilter(self, obj, event):
|
|
730
|
+
"""
|
|
731
|
+
- Keep popup header sized with container.
|
|
732
|
+
- Handle navigation/confirm keys in the header.
|
|
733
|
+
- Handle Enter on the popup list as well.
|
|
734
|
+
- Do not close popup on ESC.
|
|
735
|
+
- Keep popup horizontally inside the parent window while resizing/moving.
|
|
736
|
+
"""
|
|
737
|
+
if obj is self._popup_container and self._popup_container is not None:
|
|
738
|
+
if event.type() in (QEvent.Resize, QEvent.Show):
|
|
739
|
+
self._place_popup_header()
|
|
740
|
+
# Also ensure fitting after container geometry changes
|
|
741
|
+
self._fit_popup_to_window()
|
|
742
|
+
return False
|
|
743
|
+
|
|
744
|
+
# Track top-level parent window resize/move to keep popup clamped within it
|
|
745
|
+
if obj is self._popup_parent_window and self._popup_parent_window is not None:
|
|
746
|
+
if event.type() in (QEvent.Resize, QEvent.Move):
|
|
747
|
+
self._fit_popup_to_window()
|
|
748
|
+
return False
|
|
749
|
+
|
|
750
|
+
if obj is self._popup_header:
|
|
751
|
+
if event.type() == QEvent.KeyPress:
|
|
752
|
+
key = event.key()
|
|
753
|
+
if key == Qt.Key_Escape:
|
|
754
|
+
return True
|
|
755
|
+
if key in (Qt.Key_Up, Qt.Key_Down, Qt.Key_PageUp, Qt.Key_PageDown, Qt.Key_Home, Qt.Key_End):
|
|
756
|
+
self._handle_navigation_key(key)
|
|
757
|
+
return True
|
|
758
|
+
if key in (Qt.Key_Return, Qt.Key_Enter):
|
|
759
|
+
self._commit_view_current()
|
|
760
|
+
return True
|
|
761
|
+
return False
|
|
762
|
+
|
|
763
|
+
view = self.view()
|
|
764
|
+
if view is not None and (obj is view or obj is getattr(view, "viewport", lambda: None)()):
|
|
765
|
+
if event.type() == QEvent.KeyPress:
|
|
766
|
+
if event.key() in (Qt.Key_Return, Qt.Key_Enter):
|
|
767
|
+
self._commit_view_current()
|
|
768
|
+
return True
|
|
769
|
+
if event.key() == Qt.Key_Escape:
|
|
770
|
+
return True
|
|
771
|
+
return False
|
|
772
|
+
|
|
773
|
+
return super().eventFilter(obj, event)
|
|
774
|
+
|
|
775
|
+
# ----- Magnifier helpers -----
|
|
776
|
+
|
|
777
|
+
def _ensure_magnifier_on(self, line_edit: QLineEdit | None):
|
|
778
|
+
"""
|
|
779
|
+
Add magnifier icon to the given line edit if not already present.
|
|
780
|
+
|
|
781
|
+
:param line_edit: QLineEdit
|
|
782
|
+
"""
|
|
783
|
+
if line_edit is None:
|
|
784
|
+
return
|
|
785
|
+
try:
|
|
786
|
+
icon = QIcon(self._search_icon_path)
|
|
787
|
+
if icon.isNull():
|
|
788
|
+
return
|
|
789
|
+
if line_edit is self._search_line:
|
|
790
|
+
if self._search_action is None:
|
|
791
|
+
self._search_action = line_edit.addAction(icon, QLineEdit.LeadingPosition)
|
|
792
|
+
elif line_edit is self._popup_header:
|
|
793
|
+
if self._popup_header_action is None:
|
|
794
|
+
self._popup_header_action = line_edit.addAction(icon, QLineEdit.LeadingPosition)
|
|
795
|
+
except Exception:
|
|
796
|
+
pass
|
|
797
|
+
|
|
798
|
+
def _remove_magnifier_on(self, line_edit: QLineEdit | None):
|
|
799
|
+
"""
|
|
800
|
+
Remove magnifier icon from the given line edit if present.
|
|
801
|
+
|
|
802
|
+
:param line_edit: QLineEdit
|
|
803
|
+
"""
|
|
804
|
+
if line_edit is None:
|
|
805
|
+
return
|
|
806
|
+
try:
|
|
807
|
+
if line_edit is self._search_line and self._search_action is not None:
|
|
808
|
+
line_edit.removeAction(self._search_action)
|
|
809
|
+
self._search_action = None
|
|
810
|
+
elif line_edit is self._popup_header and self._popup_header_action is not None:
|
|
811
|
+
line_edit.removeAction(self._popup_header_action)
|
|
812
|
+
self._popup_header_action = None
|
|
813
|
+
except Exception:
|
|
814
|
+
pass
|
|
815
|
+
|
|
816
|
+
# ----- Clear button helper -----
|
|
817
|
+
|
|
818
|
+
def _ensure_clear_button_visible(self, line_edit: QLineEdit | None):
|
|
819
|
+
"""
|
|
820
|
+
Force-refresh clear button visibility to ensure the 'x' appears for programmatically restored text.
|
|
821
|
+
|
|
822
|
+
:param line_edit: QLineEdit
|
|
823
|
+
"""
|
|
824
|
+
if line_edit is None:
|
|
825
|
+
return
|
|
826
|
+
try:
|
|
827
|
+
if line_edit.text():
|
|
828
|
+
line_edit.setClearButtonEnabled(True)
|
|
829
|
+
line_edit.update()
|
|
830
|
+
except Exception:
|
|
831
|
+
pass
|
|
832
|
+
|
|
833
|
+
# ----- Display sync -----
|
|
834
|
+
|
|
835
|
+
def _sync_editor_to_current(self):
|
|
836
|
+
"""Sync the persistent editor text to the currently selected combo value."""
|
|
837
|
+
if self._search_line is None:
|
|
838
|
+
return
|
|
839
|
+
try:
|
|
840
|
+
self._search_line.blockSignals(True)
|
|
841
|
+
self._search_line.setText(self.currentText())
|
|
842
|
+
finally:
|
|
843
|
+
self._search_line.blockSignals(False)
|
|
844
|
+
|
|
845
|
+
# ----- Search helpers -----
|
|
846
|
+
|
|
847
|
+
def _find_target_row_for(self, needle_lower: str) -> int:
|
|
848
|
+
"""
|
|
849
|
+
Find row for a given lowercase needle using priority:
|
|
850
|
+
1) exact match, 2) prefix, 3) substring. Skips separators.
|
|
851
|
+
Returns -1 if not found.
|
|
852
|
+
|
|
853
|
+
:param needle_lower: lowercase search needle
|
|
854
|
+
:return: target row index or -1
|
|
855
|
+
"""
|
|
856
|
+
if needle_lower is None:
|
|
857
|
+
return -1
|
|
858
|
+
for row in range(self.count()):
|
|
859
|
+
if self.is_separator(row):
|
|
860
|
+
continue
|
|
861
|
+
txt = (self.itemText(row) or "").strip().lower()
|
|
862
|
+
if txt == needle_lower:
|
|
863
|
+
return row
|
|
864
|
+
for row in range(self.count()):
|
|
865
|
+
if self.is_separator(row):
|
|
866
|
+
continue
|
|
867
|
+
txt = (self.itemText(row) or "").lower()
|
|
868
|
+
if txt.startswith(needle_lower):
|
|
869
|
+
return row
|
|
870
|
+
for row in range(self.count()):
|
|
871
|
+
if self.is_separator(row):
|
|
872
|
+
continue
|
|
873
|
+
txt = (self.itemText(row) or "").lower()
|
|
874
|
+
if needle_lower in txt:
|
|
875
|
+
return row
|
|
876
|
+
return -1
|
|
877
|
+
|
|
878
|
+
def _prefill_with_current_or_restore_last(self):
|
|
879
|
+
"""
|
|
880
|
+
On popup open:
|
|
881
|
+
1) Prefill header with the currently selected value (if any), scroll to it and store as last query.
|
|
882
|
+
2) Otherwise, restore previously typed valid query and scroll to it.
|
|
883
|
+
3) Otherwise, clear and scroll to first valid.
|
|
884
|
+
"""
|
|
885
|
+
if not self._popup_header:
|
|
886
|
+
return
|
|
887
|
+
|
|
888
|
+
cur_idx = super().currentIndex()
|
|
889
|
+
if cur_idx is not None and cur_idx >= 0:
|
|
890
|
+
current_txt = (self.currentText() or "").strip()
|
|
891
|
+
if current_txt:
|
|
892
|
+
row = self._find_target_row_for(current_txt.lower())
|
|
893
|
+
if row == -1:
|
|
894
|
+
row = self.first_valid_index()
|
|
895
|
+
self._suppress_search = True
|
|
896
|
+
self._popup_header.setText(current_txt)
|
|
897
|
+
self._suppress_search = False
|
|
898
|
+
self._last_query_text = current_txt
|
|
899
|
+
self._ensure_clear_button_visible(self._popup_header)
|
|
900
|
+
self._popup_header.setCursorPosition(len(current_txt))
|
|
901
|
+
self._scroll_to_row(row)
|
|
902
|
+
return
|
|
903
|
+
|
|
904
|
+
last = (self._last_query_text or "").strip()
|
|
905
|
+
if last:
|
|
906
|
+
row = self._find_target_row_for(last.lower())
|
|
907
|
+
if row != -1:
|
|
908
|
+
self._suppress_search = True
|
|
909
|
+
self._popup_header.setText(last)
|
|
910
|
+
self._suppress_search = False
|
|
911
|
+
self._ensure_clear_button_visible(self._popup_header)
|
|
912
|
+
self._popup_header.setCursorPosition(len(last))
|
|
913
|
+
self._scroll_to_row(row)
|
|
914
|
+
return
|
|
915
|
+
|
|
916
|
+
self._suppress_search = True
|
|
917
|
+
self._popup_header.clear()
|
|
918
|
+
self._suppress_search = False
|
|
919
|
+
self._ensure_clear_button_visible(self._popup_header)
|
|
920
|
+
self._scroll_to_row(self.first_valid_index())
|
|
921
|
+
|
|
922
|
+
def _focus_header_async(self):
|
|
923
|
+
"""Focus the header immediately and again in the next event loop to ensure caret at the end."""
|
|
924
|
+
if not self._popup_header:
|
|
925
|
+
return
|
|
926
|
+
self._popup_header.setFocus(Qt.OtherFocusReason)
|
|
927
|
+
self._popup_header.setCursorPosition(len(self._popup_header.text()))
|
|
928
|
+
self._ensure_clear_button_visible(self._popup_header)
|
|
929
|
+
QTimer.singleShot(0, self._focus_header_end)
|
|
930
|
+
|
|
931
|
+
def _focus_header_end(self):
|
|
932
|
+
"""Focus the header and place caret at the end."""
|
|
933
|
+
if not self._popup_header:
|
|
934
|
+
return
|
|
935
|
+
self._popup_header.setFocus(Qt.OtherFocusReason)
|
|
936
|
+
self._popup_header.setCursorPosition(len(self._popup_header.text()))
|
|
937
|
+
self._ensure_clear_button_visible(self._popup_header)
|
|
938
|
+
|
|
939
|
+
# ----- Search behaviour -----
|
|
940
|
+
|
|
941
|
+
def _on_search_text_changed(self, text: str):
|
|
942
|
+
"""
|
|
943
|
+
Handle search text changes: find target row and scroll to it.
|
|
944
|
+
|
|
945
|
+
:param text: search text
|
|
946
|
+
"""
|
|
947
|
+
if self._suppress_search:
|
|
948
|
+
self._ensure_clear_button_visible(self._popup_header)
|
|
949
|
+
return
|
|
950
|
+
if not self._popup_open or not self.search:
|
|
951
|
+
return
|
|
952
|
+
|
|
953
|
+
raw = (text or "").strip()
|
|
954
|
+
needle = raw.lower()
|
|
955
|
+
target_row = -1
|
|
956
|
+
|
|
957
|
+
if not raw:
|
|
958
|
+
target_row = self.first_valid_index()
|
|
959
|
+
self._last_query_text = ""
|
|
960
|
+
else:
|
|
961
|
+
target_row = self._find_target_row_for(needle)
|
|
962
|
+
if target_row != -1:
|
|
963
|
+
self._last_query_text = raw
|
|
964
|
+
|
|
965
|
+
self._ensure_clear_button_visible(self._popup_header)
|
|
966
|
+
|
|
967
|
+
if target_row != -1:
|
|
968
|
+
self._scroll_to_row(target_row)
|
|
969
|
+
|
|
970
|
+
def _scroll_to_row(self, row: int):
|
|
971
|
+
"""
|
|
972
|
+
Scroll the popup list to place the given row at the top and highlight it.
|
|
973
|
+
|
|
974
|
+
:param row: target row index
|
|
975
|
+
"""
|
|
976
|
+
if row is None or row < 0:
|
|
977
|
+
return
|
|
978
|
+
view = self.view()
|
|
979
|
+
if view is None:
|
|
980
|
+
return
|
|
981
|
+
try:
|
|
982
|
+
model_index = self.model().index(row, self.modelColumn(), self.rootModelIndex())
|
|
983
|
+
except Exception:
|
|
984
|
+
return
|
|
985
|
+
try:
|
|
986
|
+
view.scrollTo(model_index, QAbstractItemView.PositionAtTop)
|
|
987
|
+
except Exception:
|
|
988
|
+
view.scrollTo(model_index)
|
|
989
|
+
try:
|
|
990
|
+
view.setCurrentIndex(model_index)
|
|
991
|
+
except Exception:
|
|
992
|
+
pass
|
|
993
|
+
|
|
994
|
+
# ----- Keyboard navigation while header focused -----
|
|
995
|
+
|
|
996
|
+
def _handle_navigation_key(self, key: int):
|
|
997
|
+
"""
|
|
998
|
+
Handle navigation keys in the popup header to move highlight accordingly.
|
|
999
|
+
|
|
1000
|
+
:param key: navigation key (Qt.Key_*)
|
|
1001
|
+
"""
|
|
1002
|
+
view = self.view()
|
|
1003
|
+
if view is None:
|
|
1004
|
+
return
|
|
1005
|
+
idx = view.currentIndex()
|
|
1006
|
+
row = idx.row() if idx.isValid() else self.first_valid_index()
|
|
1007
|
+
if row < 0:
|
|
1008
|
+
return
|
|
1009
|
+
|
|
1010
|
+
if key == Qt.Key_Up:
|
|
1011
|
+
self._move_to_next_valid(row - 1, -1)
|
|
1012
|
+
elif key == Qt.Key_Down:
|
|
1013
|
+
self._move_to_next_valid(row + 1, +1)
|
|
1014
|
+
elif key == Qt.Key_PageUp:
|
|
1015
|
+
self._page_move(-1)
|
|
1016
|
+
elif key == Qt.Key_PageDown:
|
|
1017
|
+
self._page_move(+1)
|
|
1018
|
+
elif key == Qt.Key_Home:
|
|
1019
|
+
first = self.first_valid_index()
|
|
1020
|
+
if first != -1:
|
|
1021
|
+
self._scroll_to_row(first)
|
|
1022
|
+
elif key == Qt.Key_End:
|
|
1023
|
+
last = self.last_valid_index()
|
|
1024
|
+
if last != -1:
|
|
1025
|
+
self._scroll_to_row(last)
|
|
1026
|
+
|
|
1027
|
+
def _row_height_hint(self) -> int:
|
|
1028
|
+
"""
|
|
1029
|
+
Get an estimated row height for the popup list.
|
|
1030
|
+
|
|
1031
|
+
:return: estimated row height in pixels
|
|
1032
|
+
"""
|
|
1033
|
+
v = self.view()
|
|
1034
|
+
if v is None:
|
|
1035
|
+
return 20
|
|
1036
|
+
try:
|
|
1037
|
+
if self.count() > 0:
|
|
1038
|
+
h = v.sizeHintForRow(0)
|
|
1039
|
+
if h and h > 0:
|
|
1040
|
+
return h
|
|
1041
|
+
except Exception:
|
|
1042
|
+
pass
|
|
1043
|
+
try:
|
|
1044
|
+
return max(18, v.fontMetrics().height() + 6)
|
|
1045
|
+
except Exception:
|
|
1046
|
+
return 20
|
|
1047
|
+
|
|
1048
|
+
def _visible_rows_in_viewport(self) -> int:
|
|
1049
|
+
"""
|
|
1050
|
+
Estimate how many rows fit in the current viewport height.
|
|
1051
|
+
|
|
1052
|
+
:return: estimated number of visible rows
|
|
1053
|
+
"""
|
|
1054
|
+
v = self.view()
|
|
1055
|
+
if v is None:
|
|
1056
|
+
return 10
|
|
1057
|
+
h = self._row_height_hint()
|
|
1058
|
+
try:
|
|
1059
|
+
viewport_h = max(1, v.viewport().height())
|
|
1060
|
+
except Exception:
|
|
1061
|
+
viewport_h = h * 10
|
|
1062
|
+
return max(1, viewport_h // max(1, h))
|
|
1063
|
+
|
|
1064
|
+
def _page_move(self, direction: int):
|
|
1065
|
+
"""
|
|
1066
|
+
Move highlight by one page up or down, skipping separators.
|
|
1067
|
+
|
|
1068
|
+
:param direction: +1 for page down, -1 for page up
|
|
1069
|
+
"""
|
|
1070
|
+
v = self.view()
|
|
1071
|
+
if v is None:
|
|
1072
|
+
return
|
|
1073
|
+
page_rows = max(1, self._visible_rows_in_viewport() - 1)
|
|
1074
|
+
idx = v.currentIndex()
|
|
1075
|
+
cur = idx.row() if idx.isValid() else self.first_valid_index()
|
|
1076
|
+
if cur < 0:
|
|
1077
|
+
return
|
|
1078
|
+
target = cur + (page_rows * (1 if direction >= 0 else -1))
|
|
1079
|
+
target = max(0, min(self.count() - 1, target))
|
|
1080
|
+
if direction >= 0:
|
|
1081
|
+
self._move_to_next_valid(target, +1)
|
|
1082
|
+
else:
|
|
1083
|
+
self._move_to_next_valid(target, -1)
|
|
1084
|
+
|
|
1085
|
+
def _move_to_next_valid(self, start_row: int, step: int):
|
|
1086
|
+
"""
|
|
1087
|
+
Move highlight to the next non-separator row from start_row in step direction.
|
|
1088
|
+
|
|
1089
|
+
:param start_row: starting row index
|
|
1090
|
+
:param step: +1 to move down, -1 to move up
|
|
1091
|
+
"""
|
|
1092
|
+
if self.count() <= 0:
|
|
1093
|
+
return
|
|
1094
|
+
row = start_row
|
|
1095
|
+
while 0 <= row < self.count():
|
|
1096
|
+
if not self.is_separator(row):
|
|
1097
|
+
self._scroll_to_row(row)
|
|
1098
|
+
return
|
|
1099
|
+
row += step
|
|
1100
|
+
|
|
1101
|
+
def _commit_view_current(self):
|
|
1102
|
+
"""Commit the currently highlighted row in the popup list and close the popup."""
|
|
1103
|
+
view = self.view()
|
|
1104
|
+
if view is None:
|
|
1105
|
+
return
|
|
1106
|
+
idx = view.currentIndex()
|
|
1107
|
+
row = idx.row() if idx.isValid() else self.first_valid_index()
|
|
1108
|
+
if row is None or row < 0:
|
|
1109
|
+
return
|
|
1110
|
+
if self.is_separator(row):
|
|
1111
|
+
forward = row + 1
|
|
1112
|
+
while forward < self.count() and self.is_separator(forward):
|
|
1113
|
+
forward += 1
|
|
1114
|
+
if forward < self.count():
|
|
1115
|
+
row = forward
|
|
1116
|
+
else:
|
|
1117
|
+
backward = row - 1
|
|
1118
|
+
while backward >= 0 and self.is_separator(backward):
|
|
1119
|
+
backward -= 1
|
|
1120
|
+
if backward >= 0:
|
|
1121
|
+
row = backward
|
|
1122
|
+
else:
|
|
1123
|
+
return
|
|
1124
|
+
self.setCurrentIndex(row)
|
|
1125
|
+
self.hidePopup()
|
|
1126
|
+
|
|
1127
|
+
# ----- Popup sizing (max height to window, compact when fewer items) -----
|
|
1128
|
+
|
|
1129
|
+
def _apply_popup_max_rows(self):
|
|
1130
|
+
"""Compute and set maxVisibleItems so the popup fits within the available window height."""
|
|
1131
|
+
try:
|
|
1132
|
+
view = self.view()
|
|
1133
|
+
if view is None:
|
|
1134
|
+
return
|
|
1135
|
+
total_rows = max(1, self.count())
|
|
1136
|
+
row_h = self._row_height_hint()
|
|
1137
|
+
header_h = self._popup_header_h \
|
|
1138
|
+
if (self.search and self._popup_open) else (self._popup_header_h if self.search else 0)
|
|
1139
|
+
|
|
1140
|
+
win = self.window()
|
|
1141
|
+
if win is not None:
|
|
1142
|
+
try:
|
|
1143
|
+
fg = win.frameGeometry()
|
|
1144
|
+
win_top = fg.top()
|
|
1145
|
+
win_bottom = fg.bottom()
|
|
1146
|
+
except Exception:
|
|
1147
|
+
win_top = None
|
|
1148
|
+
win_bottom = None
|
|
1149
|
+
else:
|
|
1150
|
+
win_top = None
|
|
1151
|
+
win_bottom = None
|
|
1152
|
+
|
|
1153
|
+
if win_top is None or win_bottom is None:
|
|
1154
|
+
try:
|
|
1155
|
+
scr = (self.window().screen() if self.window() is not None else self.screen())
|
|
1156
|
+
ag = scr.availableGeometry() if scr is not None else None
|
|
1157
|
+
if ag is not None:
|
|
1158
|
+
win_top = ag.top()
|
|
1159
|
+
win_bottom = ag.bottom()
|
|
1160
|
+
except Exception:
|
|
1161
|
+
ag = None
|
|
1162
|
+
else:
|
|
1163
|
+
ag = None
|
|
1164
|
+
|
|
1165
|
+
if win_top is None or win_bottom is None:
|
|
1166
|
+
max_rows_fit = 12
|
|
1167
|
+
else:
|
|
1168
|
+
bottom_global = self.mapToGlobal(self.rect().bottomLeft()).y()
|
|
1169
|
+
top_global = self.mapToGlobal(self.rect().topLeft()).y()
|
|
1170
|
+
|
|
1171
|
+
space_down = win_bottom - bottom_global
|
|
1172
|
+
space_up = top_global - win_top
|
|
1173
|
+
|
|
1174
|
+
usable_down = max(0, space_down - header_h - 8)
|
|
1175
|
+
usable_up = max(0, space_up - header_h - 8)
|
|
1176
|
+
max_px = max(usable_down, usable_up)
|
|
1177
|
+
max_rows_fit = max(1, int(max_px // max(1, row_h)))
|
|
1178
|
+
|
|
1179
|
+
rows = min(total_rows, max_rows_fit)
|
|
1180
|
+
rows = max(1, rows)
|
|
1181
|
+
self.setMaxVisibleItems(rows)
|
|
1182
|
+
except Exception:
|
|
1183
|
+
pass
|
|
1184
|
+
|
|
1185
|
+
# ----- Horizontal fitting helpers (keep popup inside window bounds) -----
|
|
1186
|
+
|
|
1187
|
+
def _popup_allowed_rect(self) -> QRect:
|
|
1188
|
+
"""Return the allowed global rectangle for the popup (intersection of window frame and screen)."""
|
|
1189
|
+
try:
|
|
1190
|
+
win = self.window()
|
|
1191
|
+
if win is not None:
|
|
1192
|
+
allowed = win.frameGeometry()
|
|
1193
|
+
else:
|
|
1194
|
+
scr = self.screen()
|
|
1195
|
+
allowed = scr.availableGeometry() if scr is not None else None
|
|
1196
|
+
# Intersect with the window's screen available area to avoid going off-screen
|
|
1197
|
+
scr = (win.screen() if win is not None else self.screen())
|
|
1198
|
+
if allowed is not None and scr is not None:
|
|
1199
|
+
allowed = allowed.intersected(scr.availableGeometry())
|
|
1200
|
+
if allowed is None:
|
|
1201
|
+
scr = self.screen()
|
|
1202
|
+
allowed = scr.availableGeometry() if scr is not None else QRect(0, 0, 1920, 1080)
|
|
1203
|
+
except Exception:
|
|
1204
|
+
allowed = QRect(0, 0, 1920, 1080)
|
|
1205
|
+
# Small inward adjustment to avoid touching the edge
|
|
1206
|
+
try:
|
|
1207
|
+
margin = max(0, int(self._popup_right_margin_px))
|
|
1208
|
+
except Exception:
|
|
1209
|
+
margin = 4
|
|
1210
|
+
return allowed.adjusted(margin, 0, -margin, 0)
|
|
1211
|
+
|
|
1212
|
+
def _cap_width_to_window(self, desired_width: int) -> int:
|
|
1213
|
+
"""
|
|
1214
|
+
Cap desired popup width to the allowed width inside the parent window.
|
|
1215
|
+
|
|
1216
|
+
:param desired_width: desired popup width
|
|
1217
|
+
:return: capped width
|
|
1218
|
+
"""
|
|
1219
|
+
try:
|
|
1220
|
+
allowed = self._popup_allowed_rect()
|
|
1221
|
+
max_w = max(50, allowed.width())
|
|
1222
|
+
return max(50, min(desired_width, max_w))
|
|
1223
|
+
except Exception:
|
|
1224
|
+
return desired_width
|
|
1225
|
+
|
|
1226
|
+
def _fit_popup_to_window(self):
|
|
1227
|
+
"""
|
|
1228
|
+
Ensure popup container stays horizontally within the parent window:
|
|
1229
|
+
- clamp width to allowed rect,
|
|
1230
|
+
- shift left if right edge would overflow.
|
|
1231
|
+
"""
|
|
1232
|
+
if self._fit_in_progress:
|
|
1233
|
+
return
|
|
1234
|
+
view = self.view()
|
|
1235
|
+
container = self._popup_container or (view.window() if view is not None else None)
|
|
1236
|
+
if container is None:
|
|
1237
|
+
return
|
|
1238
|
+
try:
|
|
1239
|
+
self._fit_in_progress = True
|
|
1240
|
+
|
|
1241
|
+
allowed = self._popup_allowed_rect()
|
|
1242
|
+
|
|
1243
|
+
cg = container.geometry()
|
|
1244
|
+
y, h = cg.y(), cg.height()
|
|
1245
|
+
|
|
1246
|
+
# Determine target width: prefer the larger of container or combo width, but not over allowed.
|
|
1247
|
+
desired_w = max(cg.width(), self.width())
|
|
1248
|
+
target_w = self._cap_width_to_window(desired_w)
|
|
1249
|
+
|
|
1250
|
+
# Position: keep current left if possible, otherwise shift to keep right edge inside.
|
|
1251
|
+
left_allowed = allowed.x()
|
|
1252
|
+
right_allowed = allowed.x() + allowed.width()
|
|
1253
|
+
new_x = cg.x()
|
|
1254
|
+
if new_x + target_w > right_allowed:
|
|
1255
|
+
new_x = right_allowed - target_w
|
|
1256
|
+
if new_x < left_allowed:
|
|
1257
|
+
new_x = left_allowed
|
|
1258
|
+
|
|
1259
|
+
# Apply constraints to the internal view as well to avoid relayout expanding the container back
|
|
1260
|
+
try:
|
|
1261
|
+
if view is not None:
|
|
1262
|
+
if view.minimumWidth() > target_w:
|
|
1263
|
+
view.setMinimumWidth(target_w)
|
|
1264
|
+
view.setMaximumWidth(target_w)
|
|
1265
|
+
except Exception:
|
|
1266
|
+
pass
|
|
1267
|
+
|
|
1268
|
+
if cg.x() != new_x or cg.width() != target_w:
|
|
1269
|
+
container.setGeometry(new_x, y, target_w, h)
|
|
1270
|
+
|
|
1271
|
+
# Keep header sized to new width
|
|
1272
|
+
self._place_popup_header()
|
|
1273
|
+
except Exception:
|
|
1274
|
+
pass
|
|
1275
|
+
finally:
|
|
1276
|
+
self._fit_in_progress = False
|
|
1277
|
+
|
|
1278
|
+
# ----- Internal helpers -----
|
|
1279
|
+
|
|
1280
|
+
def _refresh_popup_view(self, *_):
|
|
1281
|
+
"""Request repaint of the popup to refresh 'current-selected' mark."""
|
|
1282
|
+
v = self.view()
|
|
1283
|
+
if v is not None:
|
|
1284
|
+
try:
|
|
1285
|
+
v.viewport().update()
|
|
1286
|
+
except Exception:
|
|
1287
|
+
pass
|
|
1288
|
+
|
|
1289
|
+
|
|
1290
|
+
class NoScrollCombo(SearchableCombo):
|
|
1291
|
+
"""A combo box that disables mouse wheel scrolling, extended with optional search support."""
|
|
130
1292
|
|
|
131
1293
|
def __init__(self, parent=None):
|
|
132
1294
|
super(NoScrollCombo, self).__init__(parent)
|
|
133
1295
|
|
|
134
1296
|
def wheelEvent(self, event):
|
|
135
|
-
|
|
1297
|
+
"""
|
|
1298
|
+
Disable mouse wheel scrolling
|
|
1299
|
+
|
|
1300
|
+
:param event: QWheelEvent
|
|
1301
|
+
"""
|
|
1302
|
+
event.ignore()
|
|
136
1303
|
|
|
137
1304
|
def showPopup(self):
|
|
1305
|
+
"""Adjust popup width to fit the longest item before showing, capped to the window width."""
|
|
138
1306
|
max_width = 0
|
|
139
1307
|
font_metrics = QFontMetrics(self.font())
|
|
140
1308
|
for i in range(self.count()):
|
|
@@ -142,8 +1310,19 @@ class NoScrollCombo(SeparatorComboBox):
|
|
|
142
1310
|
width = font_metrics.horizontalAdvance(text)
|
|
143
1311
|
max_width = max(max_width, width)
|
|
144
1312
|
extra_margin = 80
|
|
145
|
-
max_width
|
|
146
|
-
|
|
1313
|
+
desired = max_width + extra_margin
|
|
1314
|
+
|
|
1315
|
+
# Cap desired width to parent window to avoid right overflow when window is not maximized
|
|
1316
|
+
capped = self._cap_width_to_window(desired)
|
|
1317
|
+
|
|
1318
|
+
try:
|
|
1319
|
+
v = self.view()
|
|
1320
|
+
if v is not None:
|
|
1321
|
+
v.setMinimumWidth(capped)
|
|
1322
|
+
v.setMaximumWidth(capped)
|
|
1323
|
+
except Exception:
|
|
1324
|
+
pass
|
|
1325
|
+
|
|
147
1326
|
super().showPopup()
|
|
148
1327
|
|
|
149
1328
|
|
|
@@ -168,12 +1347,13 @@ class OptionCombo(QWidget):
|
|
|
168
1347
|
self.keys = []
|
|
169
1348
|
self.title = ""
|
|
170
1349
|
self.real_time = False
|
|
1350
|
+
self.search = True
|
|
1351
|
+
|
|
171
1352
|
self.combo = NoScrollCombo()
|
|
172
1353
|
self.combo.currentIndexChanged.connect(self.on_combo_change)
|
|
173
1354
|
self.current_id = None
|
|
174
1355
|
self.locked = False
|
|
175
1356
|
|
|
176
|
-
# add items
|
|
177
1357
|
self.update()
|
|
178
1358
|
|
|
179
1359
|
self.layout = QHBoxLayout()
|
|
@@ -184,7 +1364,6 @@ class OptionCombo(QWidget):
|
|
|
184
1364
|
|
|
185
1365
|
def update(self):
|
|
186
1366
|
"""Prepare items"""
|
|
187
|
-
# init from option data
|
|
188
1367
|
if self.option is not None:
|
|
189
1368
|
if "label" in self.option and self.option["label"] is not None and self.option["label"] != "":
|
|
190
1369
|
self.title = trans(self.option["label"])
|
|
@@ -195,8 +1374,15 @@ class OptionCombo(QWidget):
|
|
|
195
1374
|
self.current_id = self.value
|
|
196
1375
|
if "real_time" in self.option:
|
|
197
1376
|
self.real_time = self.option["real_time"]
|
|
1377
|
+
if "search" in self.option:
|
|
1378
|
+
self.search = bool(self.option["search"])
|
|
1379
|
+
|
|
1380
|
+
try:
|
|
1381
|
+
self.combo.setSearchEnabled(self.search)
|
|
1382
|
+
except Exception:
|
|
1383
|
+
self.combo.search = self.search
|
|
198
1384
|
|
|
199
|
-
|
|
1385
|
+
self.combo.clear()
|
|
200
1386
|
if type(self.keys) is list:
|
|
201
1387
|
for item in self.keys:
|
|
202
1388
|
if type(item) is dict:
|
|
@@ -208,7 +1394,6 @@ class OptionCombo(QWidget):
|
|
|
208
1394
|
else:
|
|
209
1395
|
self.combo.addItem(value, key)
|
|
210
1396
|
else:
|
|
211
|
-
# Support simple string keys including "separator::" entries
|
|
212
1397
|
if isinstance(item, str) and item.startswith("separator::"):
|
|
213
1398
|
self.combo.addSeparator(item.split("separator::", 1)[1])
|
|
214
1399
|
else:
|
|
@@ -222,7 +1407,6 @@ class OptionCombo(QWidget):
|
|
|
222
1407
|
else:
|
|
223
1408
|
self.combo.addItem(value, key)
|
|
224
1409
|
|
|
225
|
-
# Ensure a valid non-separator selection after population
|
|
226
1410
|
self._apply_initial_selection()
|
|
227
1411
|
|
|
228
1412
|
def _apply_initial_selection(self):
|
|
@@ -231,7 +1415,6 @@ class OptionCombo(QWidget):
|
|
|
231
1415
|
Prefers self.current_id if present; otherwise selects the first valid non-separator.
|
|
232
1416
|
Signals are suppressed during this operation.
|
|
233
1417
|
"""
|
|
234
|
-
# lock on_change during initial selection
|
|
235
1418
|
prev_locked = self.locked
|
|
236
1419
|
self.locked = True
|
|
237
1420
|
try:
|
|
@@ -243,7 +1426,6 @@ class OptionCombo(QWidget):
|
|
|
243
1426
|
if index != -1:
|
|
244
1427
|
self.combo.setCurrentIndex(index)
|
|
245
1428
|
else:
|
|
246
|
-
# No valid items, clear selection
|
|
247
1429
|
self.combo.setCurrentIndex(-1)
|
|
248
1430
|
finally:
|
|
249
1431
|
self.locked = prev_locked
|
|
@@ -260,7 +1442,6 @@ class OptionCombo(QWidget):
|
|
|
260
1442
|
if index != -1:
|
|
261
1443
|
self.combo.setCurrentIndex(index)
|
|
262
1444
|
else:
|
|
263
|
-
# If requested value is not present, keep current selection but make sure it is valid.
|
|
264
1445
|
self.combo.ensure_valid_current()
|
|
265
1446
|
|
|
266
1447
|
def get_value(self):
|
|
@@ -279,12 +1460,11 @@ class OptionCombo(QWidget):
|
|
|
279
1460
|
:param lock: lock current value if True
|
|
280
1461
|
"""
|
|
281
1462
|
if lock:
|
|
282
|
-
self.locked = True
|
|
1463
|
+
self.locked = True
|
|
283
1464
|
self.keys = keys
|
|
284
1465
|
self.option["keys"] = keys
|
|
285
1466
|
self.combo.clear()
|
|
286
1467
|
self.update()
|
|
287
|
-
# After rebuilding, guarantee a non-separator selection
|
|
288
1468
|
self.combo.ensure_valid_current()
|
|
289
1469
|
if lock:
|
|
290
1470
|
self.locked = False
|
|
@@ -299,13 +1479,11 @@ class OptionCombo(QWidget):
|
|
|
299
1479
|
if self.locked:
|
|
300
1480
|
return
|
|
301
1481
|
|
|
302
|
-
# If somehow a separator got focus, correct it immediately and do not propagate invalid IDs
|
|
303
1482
|
if self.combo.is_separator(index):
|
|
304
1483
|
self.locked = True
|
|
305
1484
|
corrected = self.combo.ensure_valid_current()
|
|
306
1485
|
self.locked = False
|
|
307
1486
|
if corrected == -1:
|
|
308
|
-
# Nothing valid to select
|
|
309
1487
|
self.current_id = None
|
|
310
1488
|
return
|
|
311
1489
|
index = corrected
|