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.
Files changed (69) hide show
  1. pygpt_net/CHANGELOG.txt +12 -0
  2. pygpt_net/__init__.py +3 -3
  3. pygpt_net/controller/assistant/assistant.py +13 -8
  4. pygpt_net/controller/assistant/batch.py +29 -15
  5. pygpt_net/controller/assistant/files.py +19 -14
  6. pygpt_net/controller/assistant/store.py +63 -41
  7. pygpt_net/controller/attachment/attachment.py +45 -35
  8. pygpt_net/controller/chat/attachment.py +50 -39
  9. pygpt_net/controller/config/field/dictionary.py +26 -14
  10. pygpt_net/controller/ctx/common.py +27 -17
  11. pygpt_net/controller/ctx/ctx.py +182 -101
  12. pygpt_net/controller/files/files.py +101 -41
  13. pygpt_net/controller/idx/indexer.py +87 -31
  14. pygpt_net/controller/kernel/kernel.py +13 -2
  15. pygpt_net/controller/mode/mode.py +3 -3
  16. pygpt_net/controller/model/editor.py +70 -15
  17. pygpt_net/controller/model/importer.py +153 -54
  18. pygpt_net/controller/painter/painter.py +2 -2
  19. pygpt_net/controller/presets/experts.py +68 -15
  20. pygpt_net/controller/presets/presets.py +72 -36
  21. pygpt_net/controller/settings/profile.py +76 -35
  22. pygpt_net/controller/settings/workdir.py +70 -39
  23. pygpt_net/core/assistants/files.py +20 -18
  24. pygpt_net/core/filesystem/actions.py +111 -10
  25. pygpt_net/core/filesystem/filesystem.py +2 -1
  26. pygpt_net/core/idx/idx.py +12 -11
  27. pygpt_net/core/idx/worker.py +13 -1
  28. pygpt_net/core/models/models.py +4 -4
  29. pygpt_net/core/profile/profile.py +13 -3
  30. pygpt_net/data/config/config.json +3 -3
  31. pygpt_net/data/config/models.json +3 -3
  32. pygpt_net/data/css/style.dark.css +39 -1
  33. pygpt_net/data/css/style.light.css +39 -1
  34. pygpt_net/data/locale/locale.de.ini +3 -1
  35. pygpt_net/data/locale/locale.en.ini +3 -1
  36. pygpt_net/data/locale/locale.es.ini +3 -1
  37. pygpt_net/data/locale/locale.fr.ini +3 -1
  38. pygpt_net/data/locale/locale.it.ini +3 -1
  39. pygpt_net/data/locale/locale.pl.ini +4 -2
  40. pygpt_net/data/locale/locale.uk.ini +3 -1
  41. pygpt_net/data/locale/locale.zh.ini +3 -1
  42. pygpt_net/provider/api/openai/__init__.py +4 -2
  43. pygpt_net/provider/core/config/patch.py +9 -1
  44. pygpt_net/tools/image_viewer/tool.py +17 -0
  45. pygpt_net/tools/text_editor/tool.py +9 -0
  46. pygpt_net/ui/__init__.py +2 -2
  47. pygpt_net/ui/layout/ctx/ctx_list.py +16 -6
  48. pygpt_net/ui/main.py +3 -1
  49. pygpt_net/ui/widget/calendar/select.py +3 -3
  50. pygpt_net/ui/widget/filesystem/explorer.py +1082 -142
  51. pygpt_net/ui/widget/lists/assistant.py +185 -24
  52. pygpt_net/ui/widget/lists/assistant_store.py +245 -42
  53. pygpt_net/ui/widget/lists/attachment.py +230 -47
  54. pygpt_net/ui/widget/lists/attachment_ctx.py +189 -33
  55. pygpt_net/ui/widget/lists/base_list_combo.py +2 -2
  56. pygpt_net/ui/widget/lists/context.py +1253 -70
  57. pygpt_net/ui/widget/lists/experts.py +110 -8
  58. pygpt_net/ui/widget/lists/model_editor.py +217 -14
  59. pygpt_net/ui/widget/lists/model_importer.py +125 -6
  60. pygpt_net/ui/widget/lists/preset.py +460 -71
  61. pygpt_net/ui/widget/lists/profile.py +149 -27
  62. pygpt_net/ui/widget/lists/uploaded.py +230 -38
  63. pygpt_net/ui/widget/option/combo.py +1046 -32
  64. pygpt_net/ui/widget/option/dictionary.py +35 -7
  65. {pygpt_net-2.6.67.dist-info → pygpt_net-2.7.0.dist-info}/METADATA +14 -57
  66. {pygpt_net-2.6.67.dist-info → pygpt_net-2.7.0.dist-info}/RECORD +69 -69
  67. {pygpt_net-2.6.67.dist-info → pygpt_net-2.7.0.dist-info}/LICENSE +0 -0
  68. {pygpt_net-2.6.67.dist-info → pygpt_net-2.7.0.dist-info}/WHEEL +0 -0
  69. {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.09.02 16:00:00 #
9
+ # Updated Date: 2025.12.28 00:00:00 #
10
10
  # ================================================== #
11
11
 
12
- from PySide6.QtCore import Qt
13
- from PySide6.QtWidgets import QHBoxLayout, QWidget, QComboBox
14
- from PySide6.QtGui import QFontMetrics, QStandardItem, QStandardItemModel # keep existing imports, extend with items
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) # legacy approach used sometimes to indicate non-enabled
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
- """Returns True if item at index is a separator."""
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
- """Returns a corrected non-separator index, or -1 if none available."""
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
- class NoScrollCombo(SeparatorComboBox):
129
- """A combo box that disables mouse wheel scrolling."""
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
- event.ignore() # disable mouse wheel
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
- self.view().setMinimumWidth(max_width)
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
- # add items
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 # lock on_change
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