pygpt-net 2.6.67__py3-none-any.whl → 2.7.0__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 +12 -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 +182 -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/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 +2 -1
- pygpt_net/core/idx/idx.py +12 -11
- pygpt_net/core/idx/worker.py +13 -1
- pygpt_net/core/models/models.py +4 -4
- pygpt_net/core/profile/profile.py +13 -3
- pygpt_net/data/config/config.json +3 -3
- pygpt_net/data/config/models.json +3 -3
- pygpt_net/data/css/style.dark.css +39 -1
- pygpt_net/data/css/style.light.css +39 -1
- pygpt_net/data/locale/locale.de.ini +3 -1
- pygpt_net/data/locale/locale.en.ini +3 -1
- pygpt_net/data/locale/locale.es.ini +3 -1
- pygpt_net/data/locale/locale.fr.ini +3 -1
- pygpt_net/data/locale/locale.it.ini +3 -1
- pygpt_net/data/locale/locale.pl.ini +4 -2
- pygpt_net/data/locale/locale.uk.ini +3 -1
- pygpt_net/data/locale/locale.zh.ini +3 -1
- pygpt_net/provider/api/openai/__init__.py +4 -2
- pygpt_net/provider/core/config/patch.py +9 -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/layout/ctx/ctx_list.py +16 -6
- pygpt_net/ui/main.py +3 -1
- pygpt_net/ui/widget/calendar/select.py +3 -3
- pygpt_net/ui/widget/filesystem/explorer.py +1082 -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 +1046 -32
- pygpt_net/ui/widget/option/dictionary.py +35 -7
- {pygpt_net-2.6.67.dist-info → pygpt_net-2.7.0.dist-info}/METADATA +14 -57
- {pygpt_net-2.6.67.dist-info → pygpt_net-2.7.0.dist-info}/RECORD +69 -69
- {pygpt_net-2.6.67.dist-info → pygpt_net-2.7.0.dist-info}/LICENSE +0 -0
- {pygpt_net-2.6.67.dist-info → pygpt_net-2.7.0.dist-info}/WHEEL +0 -0
- {pygpt_net-2.6.67.dist-info → pygpt_net-2.7.0.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,992 @@ 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
|
+
self._install_persistent_editor()
|
|
397
|
+
self._init_popup_view_style_targets()
|
|
398
|
+
|
|
399
|
+
# Keep popup visuals in sync when current index changes
|
|
400
|
+
try:
|
|
401
|
+
self.currentIndexChanged.connect(self._refresh_popup_view)
|
|
402
|
+
except Exception:
|
|
403
|
+
pass
|
|
404
|
+
|
|
405
|
+
# ----- Make the popup list reliably stylable -----
|
|
406
|
+
|
|
407
|
+
def _init_popup_view_style_targets(self):
|
|
408
|
+
"""
|
|
409
|
+
Ensure the popup list can be styled by common QSS rules:
|
|
410
|
+
- Use a QListView explicitly.
|
|
411
|
+
- Install a QStyledItemDelegate so sub-control item rules can take effect.
|
|
412
|
+
- Provide stable objectNames/properties that themes (e.g., Qt Material) can target, if they rely on them.
|
|
413
|
+
- Extend with a custom delegate that allows styling the 'current-selected' row via QSS.
|
|
414
|
+
"""
|
|
415
|
+
try:
|
|
416
|
+
lv = ComboPopupListView(self, self)
|
|
417
|
+
lv.setUniformItemSizes(False)
|
|
418
|
+
self.setView(lv)
|
|
419
|
+
except Exception:
|
|
420
|
+
lv = None
|
|
421
|
+
|
|
422
|
+
try:
|
|
423
|
+
# Delegate that honors default QSS for items and adds 'current-selected' mark
|
|
424
|
+
self.setItemDelegate(CurrentSelectedDelegate(self, self))
|
|
425
|
+
except Exception:
|
|
426
|
+
try:
|
|
427
|
+
self.setItemDelegate(QStyledItemDelegate(self))
|
|
428
|
+
except Exception:
|
|
429
|
+
pass
|
|
430
|
+
|
|
431
|
+
if lv is not None:
|
|
432
|
+
try:
|
|
433
|
+
lv.setObjectName("ComboPopupList") # e.g.: QListView#ComboPopupList { ... }
|
|
434
|
+
lv.viewport().setObjectName("ComboPopupViewport")
|
|
435
|
+
except Exception:
|
|
436
|
+
pass
|
|
437
|
+
|
|
438
|
+
try:
|
|
439
|
+
# Some themes use class selectors; expose a generic one on the view and owner class name.
|
|
440
|
+
lv.setProperty("class", "combo-popup")
|
|
441
|
+
lv.setProperty("comboClass", type(self).__name__)
|
|
442
|
+
except Exception:
|
|
443
|
+
pass
|
|
444
|
+
|
|
445
|
+
# ----- Persistent editor (display only, outside the popup) -----
|
|
446
|
+
|
|
447
|
+
def _install_persistent_editor(self):
|
|
448
|
+
"""Create a persistent editor used for normal display; real search input lives in the popup header."""
|
|
449
|
+
self.setEditable(True)
|
|
450
|
+
line = QLineEdit(self)
|
|
451
|
+
line.setPlaceholderText("")
|
|
452
|
+
line.setClearButtonEnabled(False)
|
|
453
|
+
line.setReadOnly(True)
|
|
454
|
+
line.setFocusPolicy(Qt.NoFocus)
|
|
455
|
+
line.setAttribute(Qt.WA_TransparentForMouseEvents, True)
|
|
456
|
+
self.setLineEdit(line)
|
|
457
|
+
line.installEventFilter(self)
|
|
458
|
+
self._search_line = line
|
|
459
|
+
self._sync_editor_to_current()
|
|
460
|
+
|
|
461
|
+
# ----- Public API -----
|
|
462
|
+
|
|
463
|
+
def setSearchEnabled(self, enabled: bool):
|
|
464
|
+
"""
|
|
465
|
+
Enable or disable search functionality.
|
|
466
|
+
|
|
467
|
+
:param enabled: bool
|
|
468
|
+
"""
|
|
469
|
+
self.search = bool(enabled)
|
|
470
|
+
if not self.search:
|
|
471
|
+
self._teardown_popup_header()
|
|
472
|
+
if self._search_line is not None:
|
|
473
|
+
self._remove_magnifier_on(self._search_line)
|
|
474
|
+
self._search_line.setClearButtonEnabled(False)
|
|
475
|
+
self._search_line.setReadOnly(True)
|
|
476
|
+
self._search_line.setFocusPolicy(Qt.NoFocus)
|
|
477
|
+
self._sync_editor_to_current()
|
|
478
|
+
|
|
479
|
+
# ----- Popup lifecycle -----
|
|
480
|
+
|
|
481
|
+
def showPopup(self):
|
|
482
|
+
"""Open popup, set max visible height, inject header, and place it over the combo area."""
|
|
483
|
+
self._apply_popup_max_rows()
|
|
484
|
+
super().showPopup()
|
|
485
|
+
self._popup_open = True
|
|
486
|
+
|
|
487
|
+
first = self.first_valid_index()
|
|
488
|
+
if first != -1:
|
|
489
|
+
self._scroll_to_row(first)
|
|
490
|
+
|
|
491
|
+
if self.search:
|
|
492
|
+
self._prepare_popup_header()
|
|
493
|
+
|
|
494
|
+
QTimer.singleShot(0, self._apply_popup_max_rows)
|
|
495
|
+
self._refresh_popup_view()
|
|
496
|
+
|
|
497
|
+
def hidePopup(self):
|
|
498
|
+
"""Close popup and restore normal display text; remove header/margins."""
|
|
499
|
+
super().hidePopup()
|
|
500
|
+
self._popup_open = False
|
|
501
|
+
|
|
502
|
+
if self._popup_header is not None:
|
|
503
|
+
try:
|
|
504
|
+
t = (self._popup_header.text() or "").strip()
|
|
505
|
+
if t:
|
|
506
|
+
row = self._find_target_row_for(t.lower())
|
|
507
|
+
if row != -1:
|
|
508
|
+
self._last_query_text = t
|
|
509
|
+
else:
|
|
510
|
+
self._last_query_text = ""
|
|
511
|
+
except Exception:
|
|
512
|
+
pass
|
|
513
|
+
|
|
514
|
+
if self._search_line is not None:
|
|
515
|
+
self._remove_magnifier_on(self._search_line)
|
|
516
|
+
self._search_line.setClearButtonEnabled(False)
|
|
517
|
+
self._search_line.setReadOnly(True)
|
|
518
|
+
self._search_line.setFocusPolicy(Qt.NoFocus)
|
|
519
|
+
self._sync_editor_to_current()
|
|
520
|
+
|
|
521
|
+
self._teardown_popup_header()
|
|
522
|
+
|
|
523
|
+
# ----- Popup header management (search input inside popup) -----
|
|
524
|
+
|
|
525
|
+
def _prepare_popup_header(self):
|
|
526
|
+
"""
|
|
527
|
+
Create and place a search line edit inside the popup container itself.
|
|
528
|
+
The container is moved upwards by the header height so the header overlaps the combo area.
|
|
529
|
+
"""
|
|
530
|
+
view = self.view()
|
|
531
|
+
if view is None:
|
|
532
|
+
return
|
|
533
|
+
|
|
534
|
+
container = view.window()
|
|
535
|
+
if container is None:
|
|
536
|
+
return
|
|
537
|
+
|
|
538
|
+
# Expose recognizable identifiers on the popup container for stylesheet authors
|
|
539
|
+
try:
|
|
540
|
+
container.setObjectName("ComboPopupWindow") # QWidget#ComboPopupWindow { ... }
|
|
541
|
+
container.setProperty("class", "combo-popup-window")
|
|
542
|
+
except Exception:
|
|
543
|
+
pass
|
|
544
|
+
|
|
545
|
+
self._popup_container = container
|
|
546
|
+
container.installEventFilter(self)
|
|
547
|
+
|
|
548
|
+
if self._popup_header is None:
|
|
549
|
+
self._popup_header = QLineEdit(container)
|
|
550
|
+
self._popup_header.setObjectName("comboSearchHeader")
|
|
551
|
+
self._popup_header.setClearButtonEnabled(True)
|
|
552
|
+
self._popup_header.setReadOnly(False)
|
|
553
|
+
self._popup_header.setFocusPolicy(Qt.ClickFocus)
|
|
554
|
+
self._popup_header.textChanged.connect(self._on_search_text_changed)
|
|
555
|
+
self._popup_header.installEventFilter(self)
|
|
556
|
+
self._ensure_magnifier_on(self._popup_header)
|
|
557
|
+
|
|
558
|
+
self._popup_header_h = max(24, self._popup_header.sizeHint().height())
|
|
559
|
+
|
|
560
|
+
try:
|
|
561
|
+
geo: QRect = container.geometry()
|
|
562
|
+
new_geo = QRect(geo.x(), geo.y() - self._popup_header_h, geo.width(), geo.height() + self._popup_header_h)
|
|
563
|
+
container.setGeometry(new_geo)
|
|
564
|
+
except Exception:
|
|
565
|
+
pass
|
|
566
|
+
|
|
567
|
+
try:
|
|
568
|
+
view.setViewportMargins(0, self._popup_header_h, 0, 0)
|
|
569
|
+
except Exception:
|
|
570
|
+
pass
|
|
571
|
+
|
|
572
|
+
try:
|
|
573
|
+
view.installEventFilter(self)
|
|
574
|
+
if hasattr(view, "viewport"):
|
|
575
|
+
view.viewport().installEventFilter(self)
|
|
576
|
+
except Exception:
|
|
577
|
+
pass
|
|
578
|
+
|
|
579
|
+
self._place_popup_header()
|
|
580
|
+
|
|
581
|
+
if self._search_line is not None:
|
|
582
|
+
self._search_line.setReadOnly(True)
|
|
583
|
+
self._search_line.setFocusPolicy(Qt.NoFocus)
|
|
584
|
+
self._ensure_magnifier_on(self._search_line)
|
|
585
|
+
|
|
586
|
+
self._prefill_with_current_or_restore_last()
|
|
587
|
+
self._focus_header_async()
|
|
588
|
+
|
|
589
|
+
def _place_popup_header(self):
|
|
590
|
+
"""Position the header inside popup container and keep it visible."""
|
|
591
|
+
if not self._popup_container or not self._popup_header:
|
|
592
|
+
return
|
|
593
|
+
try:
|
|
594
|
+
w = self._popup_container.width()
|
|
595
|
+
h = self._popup_header_h
|
|
596
|
+
self._popup_header.setGeometry(1, 1, max(1, w - 2), h - 2)
|
|
597
|
+
self._popup_header.show()
|
|
598
|
+
except Exception:
|
|
599
|
+
pass
|
|
600
|
+
|
|
601
|
+
def _teardown_popup_header(self):
|
|
602
|
+
"""Remove margins and hide the header, leaving the container to default."""
|
|
603
|
+
view = self.view()
|
|
604
|
+
if view is not None:
|
|
605
|
+
try:
|
|
606
|
+
view.setViewportMargins(0, 0, 0, 0)
|
|
607
|
+
except Exception:
|
|
608
|
+
pass
|
|
609
|
+
try:
|
|
610
|
+
view.removeEventFilter(self)
|
|
611
|
+
if hasattr(view, "viewport"):
|
|
612
|
+
view.viewport().removeEventFilter(self)
|
|
613
|
+
except Exception:
|
|
614
|
+
pass
|
|
615
|
+
if self._popup_header is not None:
|
|
616
|
+
self._popup_header.hide()
|
|
617
|
+
if self._popup_container is not None:
|
|
618
|
+
try:
|
|
619
|
+
self._popup_container.removeEventFilter(self)
|
|
620
|
+
except Exception:
|
|
621
|
+
pass
|
|
622
|
+
self._popup_container = None
|
|
623
|
+
|
|
624
|
+
# ----- Mouse handling on combo (display area) -----
|
|
625
|
+
|
|
626
|
+
def _edit_field_rect(self):
|
|
627
|
+
"""Estimate the rectangle of the editable field area in the combo."""
|
|
628
|
+
opt = QStyleOptionComboBox()
|
|
629
|
+
self.initStyleOption(opt)
|
|
630
|
+
return self.style().subControlRect(QStyle.CC_ComboBox, opt, QStyle.SC_ComboBoxEditField, self)
|
|
631
|
+
|
|
632
|
+
def _arrow_rect(self):
|
|
633
|
+
"""Estimate the rectangle of the arrow area in the combo."""
|
|
634
|
+
opt = QStyleOptionComboBox()
|
|
635
|
+
self.initStyleOption(opt)
|
|
636
|
+
return self.style().subControlRect(QStyle.CC_ComboBox, opt, QStyle.SC_ComboBoxArrow, self)
|
|
637
|
+
|
|
638
|
+
def mousePressEvent(self, event):
|
|
639
|
+
"""
|
|
640
|
+
Open popup on left-click anywhere in the combo area; let the arrow retain default toggle behaviour.
|
|
641
|
+
|
|
642
|
+
:param event: QMouseEvent
|
|
643
|
+
"""
|
|
644
|
+
if event.button() == Qt.LeftButton and self.isEnabled():
|
|
645
|
+
arrow_rect = self._arrow_rect()
|
|
646
|
+
if arrow_rect.contains(event.pos()):
|
|
647
|
+
return super().mousePressEvent(event)
|
|
648
|
+
if not self._popup_open:
|
|
649
|
+
self.showPopup()
|
|
650
|
+
event.accept()
|
|
651
|
+
return
|
|
652
|
+
super().mousePressEvent(event)
|
|
653
|
+
|
|
654
|
+
def keyPressEvent(self, event):
|
|
655
|
+
"""
|
|
656
|
+
Commit the highlighted item with Enter/Return while the popup is open.
|
|
657
|
+
|
|
658
|
+
:param event: QKeyEvent
|
|
659
|
+
"""
|
|
660
|
+
if self._popup_open:
|
|
661
|
+
if event.key() in (Qt.Key_Return, Qt.Key_Enter):
|
|
662
|
+
self._commit_view_current()
|
|
663
|
+
event.accept()
|
|
664
|
+
return
|
|
665
|
+
if event.key() == Qt.Key_Escape:
|
|
666
|
+
event.accept()
|
|
667
|
+
return
|
|
668
|
+
super().keyPressEvent(event)
|
|
669
|
+
|
|
670
|
+
# ----- Event filter -----
|
|
671
|
+
|
|
672
|
+
def eventFilter(self, obj, event):
|
|
673
|
+
"""
|
|
674
|
+
- Keep popup header sized with container.
|
|
675
|
+
- Handle navigation/confirm keys in the header.
|
|
676
|
+
- Handle Enter on the popup list as well.
|
|
677
|
+
- Do not close popup on ESC.
|
|
678
|
+
|
|
679
|
+
:param obj: QObject
|
|
680
|
+
:param event: QEvent
|
|
681
|
+
"""
|
|
682
|
+
if obj is self._popup_container and self._popup_container is not None:
|
|
683
|
+
if event.type() in (QEvent.Resize, QEvent.Show):
|
|
684
|
+
self._place_popup_header()
|
|
685
|
+
return False
|
|
686
|
+
|
|
687
|
+
if obj is self._popup_header:
|
|
688
|
+
if event.type() == QEvent.KeyPress:
|
|
689
|
+
key = event.key()
|
|
690
|
+
if key == Qt.Key_Escape:
|
|
691
|
+
return True
|
|
692
|
+
if key in (Qt.Key_Up, Qt.Key_Down, Qt.Key_PageUp, Qt.Key_PageDown, Qt.Key_Home, Qt.Key_End):
|
|
693
|
+
self._handle_navigation_key(key)
|
|
694
|
+
return True
|
|
695
|
+
if key in (Qt.Key_Return, Qt.Key_Enter):
|
|
696
|
+
self._commit_view_current()
|
|
697
|
+
return True
|
|
698
|
+
return False
|
|
699
|
+
|
|
700
|
+
view = self.view()
|
|
701
|
+
if view is not None and (obj is view or obj is getattr(view, "viewport", lambda: None)()):
|
|
702
|
+
if event.type() == QEvent.KeyPress:
|
|
703
|
+
if event.key() in (Qt.Key_Return, Qt.Key_Enter):
|
|
704
|
+
self._commit_view_current()
|
|
705
|
+
return True
|
|
706
|
+
if event.key() == Qt.Key_Escape:
|
|
707
|
+
return True
|
|
708
|
+
return False
|
|
709
|
+
|
|
710
|
+
return super().eventFilter(obj, event)
|
|
711
|
+
|
|
712
|
+
# ----- Magnifier helpers -----
|
|
713
|
+
|
|
714
|
+
def _ensure_magnifier_on(self, line_edit: QLineEdit | None):
|
|
715
|
+
"""
|
|
716
|
+
Add magnifier icon to the given line edit if not already present.
|
|
717
|
+
|
|
718
|
+
:param line_edit: QLineEdit
|
|
719
|
+
"""
|
|
720
|
+
if line_edit is None:
|
|
721
|
+
return
|
|
722
|
+
try:
|
|
723
|
+
icon = QIcon(self._search_icon_path)
|
|
724
|
+
if icon.isNull():
|
|
725
|
+
return
|
|
726
|
+
if line_edit is self._search_line:
|
|
727
|
+
if self._search_action is None:
|
|
728
|
+
self._search_action = line_edit.addAction(icon, QLineEdit.LeadingPosition)
|
|
729
|
+
elif line_edit is self._popup_header:
|
|
730
|
+
if self._popup_header_action is None:
|
|
731
|
+
self._popup_header_action = line_edit.addAction(icon, QLineEdit.LeadingPosition)
|
|
732
|
+
except Exception:
|
|
733
|
+
pass
|
|
734
|
+
|
|
735
|
+
def _remove_magnifier_on(self, line_edit: QLineEdit | None):
|
|
736
|
+
"""
|
|
737
|
+
Remove magnifier icon from the given line edit if present.
|
|
738
|
+
|
|
739
|
+
:param line_edit: QLineEdit
|
|
740
|
+
"""
|
|
741
|
+
if line_edit is None:
|
|
742
|
+
return
|
|
743
|
+
try:
|
|
744
|
+
if line_edit is self._search_line and self._search_action is not None:
|
|
745
|
+
line_edit.removeAction(self._search_action)
|
|
746
|
+
self._search_action = None
|
|
747
|
+
elif line_edit is self._popup_header and self._popup_header_action is not None:
|
|
748
|
+
line_edit.removeAction(self._popup_header_action)
|
|
749
|
+
self._popup_header_action = None
|
|
750
|
+
except Exception:
|
|
751
|
+
pass
|
|
752
|
+
|
|
753
|
+
# ----- Clear button helper -----
|
|
754
|
+
|
|
755
|
+
def _ensure_clear_button_visible(self, line_edit: QLineEdit | None):
|
|
756
|
+
"""
|
|
757
|
+
Force-refresh clear button visibility to ensure the 'x' appears for programmatically restored text.
|
|
758
|
+
|
|
759
|
+
:param line_edit: QLineEdit
|
|
760
|
+
"""
|
|
761
|
+
if line_edit is None:
|
|
762
|
+
return
|
|
763
|
+
try:
|
|
764
|
+
if line_edit.text():
|
|
765
|
+
line_edit.setClearButtonEnabled(True)
|
|
766
|
+
line_edit.update()
|
|
767
|
+
except Exception:
|
|
768
|
+
pass
|
|
769
|
+
|
|
770
|
+
# ----- Display sync -----
|
|
771
|
+
|
|
772
|
+
def _sync_editor_to_current(self):
|
|
773
|
+
"""Sync the persistent editor text to the currently selected combo value."""
|
|
774
|
+
if self._search_line is None:
|
|
775
|
+
return
|
|
776
|
+
try:
|
|
777
|
+
self._search_line.blockSignals(True)
|
|
778
|
+
self._search_line.setText(self.currentText())
|
|
779
|
+
finally:
|
|
780
|
+
self._search_line.blockSignals(False)
|
|
781
|
+
|
|
782
|
+
# ----- Search helpers -----
|
|
783
|
+
|
|
784
|
+
def _find_target_row_for(self, needle_lower: str) -> int:
|
|
785
|
+
"""
|
|
786
|
+
Find row for a given lowercase needle using priority:
|
|
787
|
+
1) exact match, 2) prefix, 3) substring. Skips separators.
|
|
788
|
+
Returns -1 if not found.
|
|
789
|
+
|
|
790
|
+
:param needle_lower: lowercase search needle
|
|
791
|
+
:return: target row index or -1
|
|
792
|
+
"""
|
|
793
|
+
if needle_lower is None:
|
|
794
|
+
return -1
|
|
795
|
+
for row in range(self.count()):
|
|
796
|
+
if self.is_separator(row):
|
|
797
|
+
continue
|
|
798
|
+
txt = (self.itemText(row) or "").strip().lower()
|
|
799
|
+
if txt == needle_lower:
|
|
800
|
+
return row
|
|
801
|
+
for row in range(self.count()):
|
|
802
|
+
if self.is_separator(row):
|
|
803
|
+
continue
|
|
804
|
+
txt = (self.itemText(row) or "").lower()
|
|
805
|
+
if txt.startswith(needle_lower):
|
|
806
|
+
return row
|
|
807
|
+
for row in range(self.count()):
|
|
808
|
+
if self.is_separator(row):
|
|
809
|
+
continue
|
|
810
|
+
txt = (self.itemText(row) or "").lower()
|
|
811
|
+
if needle_lower in txt:
|
|
812
|
+
return row
|
|
813
|
+
return -1
|
|
814
|
+
|
|
815
|
+
def _prefill_with_current_or_restore_last(self):
|
|
816
|
+
"""
|
|
817
|
+
On popup open:
|
|
818
|
+
1) Prefill header with the currently selected value (if any), scroll to it and store as last query.
|
|
819
|
+
2) Otherwise, restore previously typed valid query and scroll to it.
|
|
820
|
+
3) Otherwise, clear and scroll to first valid.
|
|
821
|
+
"""
|
|
822
|
+
if not self._popup_header:
|
|
823
|
+
return
|
|
824
|
+
|
|
825
|
+
cur_idx = super().currentIndex()
|
|
826
|
+
if cur_idx is not None and cur_idx >= 0:
|
|
827
|
+
current_txt = (self.currentText() or "").strip()
|
|
828
|
+
if current_txt:
|
|
829
|
+
row = self._find_target_row_for(current_txt.lower())
|
|
830
|
+
if row == -1:
|
|
831
|
+
row = self.first_valid_index()
|
|
832
|
+
self._suppress_search = True
|
|
833
|
+
self._popup_header.setText(current_txt)
|
|
834
|
+
self._suppress_search = False
|
|
835
|
+
self._last_query_text = current_txt
|
|
836
|
+
self._ensure_clear_button_visible(self._popup_header)
|
|
837
|
+
self._popup_header.setCursorPosition(len(current_txt))
|
|
838
|
+
self._scroll_to_row(row)
|
|
839
|
+
return
|
|
840
|
+
|
|
841
|
+
last = (self._last_query_text or "").strip()
|
|
842
|
+
if last:
|
|
843
|
+
row = self._find_target_row_for(last.lower())
|
|
844
|
+
if row != -1:
|
|
845
|
+
self._suppress_search = True
|
|
846
|
+
self._popup_header.setText(last)
|
|
847
|
+
self._suppress_search = False
|
|
848
|
+
self._ensure_clear_button_visible(self._popup_header)
|
|
849
|
+
self._popup_header.setCursorPosition(len(last))
|
|
850
|
+
self._scroll_to_row(row)
|
|
851
|
+
return
|
|
852
|
+
|
|
853
|
+
self._suppress_search = True
|
|
854
|
+
self._popup_header.clear()
|
|
855
|
+
self._suppress_search = False
|
|
856
|
+
self._ensure_clear_button_visible(self._popup_header)
|
|
857
|
+
self._scroll_to_row(self.first_valid_index())
|
|
858
|
+
|
|
859
|
+
def _focus_header_async(self):
|
|
860
|
+
"""Focus the header immediately and again in the next event loop to ensure caret at the end."""
|
|
861
|
+
if not self._popup_header:
|
|
862
|
+
return
|
|
863
|
+
self._popup_header.setFocus(Qt.OtherFocusReason)
|
|
864
|
+
self._popup_header.setCursorPosition(len(self._popup_header.text()))
|
|
865
|
+
self._ensure_clear_button_visible(self._popup_header)
|
|
866
|
+
QTimer.singleShot(0, self._focus_header_end)
|
|
867
|
+
|
|
868
|
+
def _focus_header_end(self):
|
|
869
|
+
"""Focus the header and place caret at the end."""
|
|
870
|
+
if not self._popup_header:
|
|
871
|
+
return
|
|
872
|
+
self._popup_header.setFocus(Qt.OtherFocusReason)
|
|
873
|
+
self._popup_header.setCursorPosition(len(self._popup_header.text()))
|
|
874
|
+
self._ensure_clear_button_visible(self._popup_header)
|
|
875
|
+
|
|
876
|
+
# ----- Search behaviour -----
|
|
877
|
+
|
|
878
|
+
def _on_search_text_changed(self, text: str):
|
|
879
|
+
"""
|
|
880
|
+
Handle search text changes: find target row and scroll to it.
|
|
881
|
+
|
|
882
|
+
:param text: search text
|
|
883
|
+
"""
|
|
884
|
+
if self._suppress_search:
|
|
885
|
+
self._ensure_clear_button_visible(self._popup_header)
|
|
886
|
+
return
|
|
887
|
+
if not self._popup_open or not self.search:
|
|
888
|
+
return
|
|
889
|
+
|
|
890
|
+
raw = (text or "").strip()
|
|
891
|
+
needle = raw.lower()
|
|
892
|
+
target_row = -1
|
|
893
|
+
|
|
894
|
+
if not raw:
|
|
895
|
+
target_row = self.first_valid_index()
|
|
896
|
+
self._last_query_text = ""
|
|
897
|
+
else:
|
|
898
|
+
target_row = self._find_target_row_for(needle)
|
|
899
|
+
if target_row != -1:
|
|
900
|
+
self._last_query_text = raw
|
|
901
|
+
|
|
902
|
+
self._ensure_clear_button_visible(self._popup_header)
|
|
903
|
+
|
|
904
|
+
if target_row != -1:
|
|
905
|
+
self._scroll_to_row(target_row)
|
|
906
|
+
|
|
907
|
+
def _scroll_to_row(self, row: int):
|
|
908
|
+
"""
|
|
909
|
+
Scroll the popup list to place the given row at the top and highlight it.
|
|
910
|
+
|
|
911
|
+
:param row: target row index
|
|
912
|
+
"""
|
|
913
|
+
if row is None or row < 0:
|
|
914
|
+
return
|
|
915
|
+
view = self.view()
|
|
916
|
+
if view is None:
|
|
917
|
+
return
|
|
918
|
+
try:
|
|
919
|
+
model_index = self.model().index(row, self.modelColumn(), self.rootModelIndex())
|
|
920
|
+
except Exception:
|
|
921
|
+
return
|
|
922
|
+
try:
|
|
923
|
+
view.scrollTo(model_index, QAbstractItemView.PositionAtTop)
|
|
924
|
+
except Exception:
|
|
925
|
+
view.scrollTo(model_index)
|
|
926
|
+
try:
|
|
927
|
+
view.setCurrentIndex(model_index)
|
|
928
|
+
except Exception:
|
|
929
|
+
pass
|
|
930
|
+
|
|
931
|
+
# ----- Keyboard navigation while header focused -----
|
|
932
|
+
|
|
933
|
+
def _handle_navigation_key(self, key: int):
|
|
934
|
+
"""
|
|
935
|
+
Handle navigation keys in the popup header to move highlight accordingly.
|
|
936
|
+
|
|
937
|
+
:param key: navigation key (Qt.Key_*)
|
|
938
|
+
"""
|
|
939
|
+
view = self.view()
|
|
940
|
+
if view is None:
|
|
941
|
+
return
|
|
942
|
+
idx = view.currentIndex()
|
|
943
|
+
row = idx.row() if idx.isValid() else self.first_valid_index()
|
|
944
|
+
if row < 0:
|
|
945
|
+
return
|
|
946
|
+
|
|
947
|
+
if key == Qt.Key_Up:
|
|
948
|
+
self._move_to_next_valid(row - 1, -1)
|
|
949
|
+
elif key == Qt.Key_Down:
|
|
950
|
+
self._move_to_next_valid(row + 1, +1)
|
|
951
|
+
elif key == Qt.Key_PageUp:
|
|
952
|
+
self._page_move(-1)
|
|
953
|
+
elif key == Qt.Key_PageDown:
|
|
954
|
+
self._page_move(+1)
|
|
955
|
+
elif key == Qt.Key_Home:
|
|
956
|
+
first = self.first_valid_index()
|
|
957
|
+
if first != -1:
|
|
958
|
+
self._scroll_to_row(first)
|
|
959
|
+
elif key == Qt.Key_End:
|
|
960
|
+
last = self.last_valid_index()
|
|
961
|
+
if last != -1:
|
|
962
|
+
self._scroll_to_row(last)
|
|
963
|
+
|
|
964
|
+
def _row_height_hint(self) -> int:
|
|
965
|
+
"""
|
|
966
|
+
Get an estimated row height for the popup list.
|
|
967
|
+
|
|
968
|
+
:return: estimated row height in pixels
|
|
969
|
+
"""
|
|
970
|
+
v = self.view()
|
|
971
|
+
if v is None:
|
|
972
|
+
return 20
|
|
973
|
+
try:
|
|
974
|
+
if self.count() > 0:
|
|
975
|
+
h = v.sizeHintForRow(0)
|
|
976
|
+
if h and h > 0:
|
|
977
|
+
return h
|
|
978
|
+
except Exception:
|
|
979
|
+
pass
|
|
980
|
+
try:
|
|
981
|
+
return max(18, v.fontMetrics().height() + 6)
|
|
982
|
+
except Exception:
|
|
983
|
+
return 20
|
|
984
|
+
|
|
985
|
+
def _visible_rows_in_viewport(self) -> int:
|
|
986
|
+
"""
|
|
987
|
+
Estimate how many rows fit in the current viewport height.
|
|
988
|
+
|
|
989
|
+
:return: estimated number of visible rows
|
|
990
|
+
"""
|
|
991
|
+
v = self.view()
|
|
992
|
+
if v is None:
|
|
993
|
+
return 10
|
|
994
|
+
h = self._row_height_hint()
|
|
995
|
+
try:
|
|
996
|
+
viewport_h = max(1, v.viewport().height())
|
|
997
|
+
except Exception:
|
|
998
|
+
viewport_h = h * 10
|
|
999
|
+
return max(1, viewport_h // max(1, h))
|
|
1000
|
+
|
|
1001
|
+
def _page_move(self, direction: int):
|
|
1002
|
+
"""
|
|
1003
|
+
Move highlight by one page up or down, skipping separators.
|
|
1004
|
+
|
|
1005
|
+
:param direction: +1 for page down, -1 for page up
|
|
1006
|
+
"""
|
|
1007
|
+
v = self.view()
|
|
1008
|
+
if v is None:
|
|
1009
|
+
return
|
|
1010
|
+
page_rows = max(1, self._visible_rows_in_viewport() - 1)
|
|
1011
|
+
idx = v.currentIndex()
|
|
1012
|
+
cur = idx.row() if idx.isValid() else self.first_valid_index()
|
|
1013
|
+
if cur < 0:
|
|
1014
|
+
return
|
|
1015
|
+
target = cur + (page_rows * (1 if direction >= 0 else -1))
|
|
1016
|
+
target = max(0, min(self.count() - 1, target))
|
|
1017
|
+
if direction >= 0:
|
|
1018
|
+
self._move_to_next_valid(target, +1)
|
|
1019
|
+
else:
|
|
1020
|
+
self._move_to_next_valid(target, -1)
|
|
1021
|
+
|
|
1022
|
+
def _move_to_next_valid(self, start_row: int, step: int):
|
|
1023
|
+
"""
|
|
1024
|
+
Move highlight to the next non-separator row from start_row in step direction.
|
|
1025
|
+
|
|
1026
|
+
:param start_row: starting row index
|
|
1027
|
+
:param step: +1 to move down, -1 to move up
|
|
1028
|
+
"""
|
|
1029
|
+
if self.count() <= 0:
|
|
1030
|
+
return
|
|
1031
|
+
row = start_row
|
|
1032
|
+
while 0 <= row < self.count():
|
|
1033
|
+
if not self.is_separator(row):
|
|
1034
|
+
self._scroll_to_row(row)
|
|
1035
|
+
return
|
|
1036
|
+
row += step
|
|
1037
|
+
|
|
1038
|
+
def _commit_view_current(self):
|
|
1039
|
+
"""Commit the currently highlighted row in the popup list and close the popup."""
|
|
1040
|
+
view = self.view()
|
|
1041
|
+
if view is None:
|
|
1042
|
+
return
|
|
1043
|
+
idx = view.currentIndex()
|
|
1044
|
+
row = idx.row() if idx.isValid() else self.first_valid_index()
|
|
1045
|
+
if row is None or row < 0:
|
|
1046
|
+
return
|
|
1047
|
+
if self.is_separator(row):
|
|
1048
|
+
forward = row + 1
|
|
1049
|
+
while forward < self.count() and self.is_separator(forward):
|
|
1050
|
+
forward += 1
|
|
1051
|
+
if forward < self.count():
|
|
1052
|
+
row = forward
|
|
1053
|
+
else:
|
|
1054
|
+
backward = row - 1
|
|
1055
|
+
while backward >= 0 and self.is_separator(backward):
|
|
1056
|
+
backward -= 1
|
|
1057
|
+
if backward >= 0:
|
|
1058
|
+
row = backward
|
|
1059
|
+
else:
|
|
1060
|
+
return
|
|
1061
|
+
self.setCurrentIndex(row)
|
|
1062
|
+
self.hidePopup()
|
|
1063
|
+
|
|
1064
|
+
# ----- Popup sizing (max height to window, compact when fewer items) -----
|
|
1065
|
+
|
|
1066
|
+
def _apply_popup_max_rows(self):
|
|
1067
|
+
"""Compute and set maxVisibleItems so the popup fits within the available window height."""
|
|
1068
|
+
try:
|
|
1069
|
+
view = self.view()
|
|
1070
|
+
if view is None:
|
|
1071
|
+
return
|
|
1072
|
+
total_rows = max(1, self.count())
|
|
1073
|
+
row_h = self._row_height_hint()
|
|
1074
|
+
header_h = self._popup_header_h \
|
|
1075
|
+
if (self.search and self._popup_open) else (self._popup_header_h if self.search else 0)
|
|
1076
|
+
|
|
1077
|
+
win = self.window()
|
|
1078
|
+
if win is not None:
|
|
1079
|
+
try:
|
|
1080
|
+
fg = win.frameGeometry()
|
|
1081
|
+
win_top = fg.top()
|
|
1082
|
+
win_bottom = fg.bottom()
|
|
1083
|
+
except Exception:
|
|
1084
|
+
win_top = None
|
|
1085
|
+
win_bottom = None
|
|
1086
|
+
else:
|
|
1087
|
+
win_top = None
|
|
1088
|
+
win_bottom = None
|
|
1089
|
+
|
|
1090
|
+
if win_top is None or win_bottom is None:
|
|
1091
|
+
try:
|
|
1092
|
+
scr = (self.window().screen() if self.window() is not None else self.screen())
|
|
1093
|
+
ag = scr.availableGeometry() if scr is not None else None
|
|
1094
|
+
if ag is not None:
|
|
1095
|
+
win_top = ag.top()
|
|
1096
|
+
win_bottom = ag.bottom()
|
|
1097
|
+
except Exception:
|
|
1098
|
+
ag = None
|
|
1099
|
+
else:
|
|
1100
|
+
ag = None
|
|
1101
|
+
|
|
1102
|
+
if win_top is None or win_bottom is None:
|
|
1103
|
+
max_rows_fit = 12
|
|
1104
|
+
else:
|
|
1105
|
+
bottom_global = self.mapToGlobal(self.rect().bottomLeft()).y()
|
|
1106
|
+
top_global = self.mapToGlobal(self.rect().topLeft()).y()
|
|
1107
|
+
|
|
1108
|
+
space_down = win_bottom - bottom_global
|
|
1109
|
+
space_up = top_global - win_top
|
|
1110
|
+
|
|
1111
|
+
usable_down = max(0, space_down - header_h - 8)
|
|
1112
|
+
usable_up = max(0, space_up - header_h - 8)
|
|
1113
|
+
max_px = max(usable_down, usable_up)
|
|
1114
|
+
max_rows_fit = max(1, int(max_px // max(1, row_h)))
|
|
1115
|
+
|
|
1116
|
+
rows = min(total_rows, max_rows_fit)
|
|
1117
|
+
rows = max(1, rows)
|
|
1118
|
+
self.setMaxVisibleItems(rows)
|
|
1119
|
+
except Exception:
|
|
1120
|
+
pass
|
|
1121
|
+
|
|
1122
|
+
# ----- Internal helpers -----
|
|
1123
|
+
|
|
1124
|
+
def _refresh_popup_view(self, *_):
|
|
1125
|
+
"""Request repaint of the popup to refresh 'current-selected' mark."""
|
|
1126
|
+
v = self.view()
|
|
1127
|
+
if v is not None:
|
|
1128
|
+
try:
|
|
1129
|
+
v.viewport().update()
|
|
1130
|
+
except Exception:
|
|
1131
|
+
pass
|
|
1132
|
+
|
|
1133
|
+
|
|
1134
|
+
class NoScrollCombo(SearchableCombo):
|
|
1135
|
+
"""A combo box that disables mouse wheel scrolling, extended with optional search support."""
|
|
130
1136
|
|
|
131
1137
|
def __init__(self, parent=None):
|
|
132
1138
|
super(NoScrollCombo, self).__init__(parent)
|
|
133
1139
|
|
|
134
1140
|
def wheelEvent(self, event):
|
|
135
|
-
|
|
1141
|
+
"""
|
|
1142
|
+
Disable mouse wheel scrolling
|
|
1143
|
+
|
|
1144
|
+
:param event: QWheelEvent
|
|
1145
|
+
"""
|
|
1146
|
+
event.ignore()
|
|
136
1147
|
|
|
137
1148
|
def showPopup(self):
|
|
1149
|
+
"""Adjust popup width to fit the longest item before showing."""
|
|
138
1150
|
max_width = 0
|
|
139
1151
|
font_metrics = QFontMetrics(self.font())
|
|
140
1152
|
for i in range(self.count()):
|
|
@@ -143,7 +1155,10 @@ class NoScrollCombo(SeparatorComboBox):
|
|
|
143
1155
|
max_width = max(max_width, width)
|
|
144
1156
|
extra_margin = 80
|
|
145
1157
|
max_width += extra_margin
|
|
146
|
-
|
|
1158
|
+
try:
|
|
1159
|
+
self.view().setMinimumWidth(max_width)
|
|
1160
|
+
except Exception:
|
|
1161
|
+
pass
|
|
147
1162
|
super().showPopup()
|
|
148
1163
|
|
|
149
1164
|
|
|
@@ -168,12 +1183,13 @@ class OptionCombo(QWidget):
|
|
|
168
1183
|
self.keys = []
|
|
169
1184
|
self.title = ""
|
|
170
1185
|
self.real_time = False
|
|
1186
|
+
self.search = True
|
|
1187
|
+
|
|
171
1188
|
self.combo = NoScrollCombo()
|
|
172
1189
|
self.combo.currentIndexChanged.connect(self.on_combo_change)
|
|
173
1190
|
self.current_id = None
|
|
174
1191
|
self.locked = False
|
|
175
1192
|
|
|
176
|
-
# add items
|
|
177
1193
|
self.update()
|
|
178
1194
|
|
|
179
1195
|
self.layout = QHBoxLayout()
|
|
@@ -184,7 +1200,6 @@ class OptionCombo(QWidget):
|
|
|
184
1200
|
|
|
185
1201
|
def update(self):
|
|
186
1202
|
"""Prepare items"""
|
|
187
|
-
# init from option data
|
|
188
1203
|
if self.option is not None:
|
|
189
1204
|
if "label" in self.option and self.option["label"] is not None and self.option["label"] != "":
|
|
190
1205
|
self.title = trans(self.option["label"])
|
|
@@ -195,8 +1210,15 @@ class OptionCombo(QWidget):
|
|
|
195
1210
|
self.current_id = self.value
|
|
196
1211
|
if "real_time" in self.option:
|
|
197
1212
|
self.real_time = self.option["real_time"]
|
|
1213
|
+
if "search" in self.option:
|
|
1214
|
+
self.search = bool(self.option["search"])
|
|
198
1215
|
|
|
199
|
-
|
|
1216
|
+
try:
|
|
1217
|
+
self.combo.setSearchEnabled(self.search)
|
|
1218
|
+
except Exception:
|
|
1219
|
+
self.combo.search = self.search
|
|
1220
|
+
|
|
1221
|
+
self.combo.clear()
|
|
200
1222
|
if type(self.keys) is list:
|
|
201
1223
|
for item in self.keys:
|
|
202
1224
|
if type(item) is dict:
|
|
@@ -208,7 +1230,6 @@ class OptionCombo(QWidget):
|
|
|
208
1230
|
else:
|
|
209
1231
|
self.combo.addItem(value, key)
|
|
210
1232
|
else:
|
|
211
|
-
# Support simple string keys including "separator::" entries
|
|
212
1233
|
if isinstance(item, str) and item.startswith("separator::"):
|
|
213
1234
|
self.combo.addSeparator(item.split("separator::", 1)[1])
|
|
214
1235
|
else:
|
|
@@ -222,7 +1243,6 @@ class OptionCombo(QWidget):
|
|
|
222
1243
|
else:
|
|
223
1244
|
self.combo.addItem(value, key)
|
|
224
1245
|
|
|
225
|
-
# Ensure a valid non-separator selection after population
|
|
226
1246
|
self._apply_initial_selection()
|
|
227
1247
|
|
|
228
1248
|
def _apply_initial_selection(self):
|
|
@@ -231,7 +1251,6 @@ class OptionCombo(QWidget):
|
|
|
231
1251
|
Prefers self.current_id if present; otherwise selects the first valid non-separator.
|
|
232
1252
|
Signals are suppressed during this operation.
|
|
233
1253
|
"""
|
|
234
|
-
# lock on_change during initial selection
|
|
235
1254
|
prev_locked = self.locked
|
|
236
1255
|
self.locked = True
|
|
237
1256
|
try:
|
|
@@ -243,7 +1262,6 @@ class OptionCombo(QWidget):
|
|
|
243
1262
|
if index != -1:
|
|
244
1263
|
self.combo.setCurrentIndex(index)
|
|
245
1264
|
else:
|
|
246
|
-
# No valid items, clear selection
|
|
247
1265
|
self.combo.setCurrentIndex(-1)
|
|
248
1266
|
finally:
|
|
249
1267
|
self.locked = prev_locked
|
|
@@ -260,7 +1278,6 @@ class OptionCombo(QWidget):
|
|
|
260
1278
|
if index != -1:
|
|
261
1279
|
self.combo.setCurrentIndex(index)
|
|
262
1280
|
else:
|
|
263
|
-
# If requested value is not present, keep current selection but make sure it is valid.
|
|
264
1281
|
self.combo.ensure_valid_current()
|
|
265
1282
|
|
|
266
1283
|
def get_value(self):
|
|
@@ -279,12 +1296,11 @@ class OptionCombo(QWidget):
|
|
|
279
1296
|
:param lock: lock current value if True
|
|
280
1297
|
"""
|
|
281
1298
|
if lock:
|
|
282
|
-
self.locked = True
|
|
1299
|
+
self.locked = True
|
|
283
1300
|
self.keys = keys
|
|
284
1301
|
self.option["keys"] = keys
|
|
285
1302
|
self.combo.clear()
|
|
286
1303
|
self.update()
|
|
287
|
-
# After rebuilding, guarantee a non-separator selection
|
|
288
1304
|
self.combo.ensure_valid_current()
|
|
289
1305
|
if lock:
|
|
290
1306
|
self.locked = False
|
|
@@ -299,13 +1315,11 @@ class OptionCombo(QWidget):
|
|
|
299
1315
|
if self.locked:
|
|
300
1316
|
return
|
|
301
1317
|
|
|
302
|
-
# If somehow a separator got focus, correct it immediately and do not propagate invalid IDs
|
|
303
1318
|
if self.combo.is_separator(index):
|
|
304
1319
|
self.locked = True
|
|
305
1320
|
corrected = self.combo.ensure_valid_current()
|
|
306
1321
|
self.locked = False
|
|
307
1322
|
if corrected == -1:
|
|
308
|
-
# Nothing valid to select
|
|
309
1323
|
self.current_id = None
|
|
310
1324
|
return
|
|
311
1325
|
index = corrected
|