pygpt-net 2.6.67__py3-none-any.whl → 2.7.1__py3-none-any.whl

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