pygpt-net 2.7.2__py3-none-any.whl → 2.7.4__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 (48) hide show
  1. pygpt_net/CHANGELOG.txt +12 -0
  2. pygpt_net/__init__.py +3 -3
  3. pygpt_net/app.py +382 -350
  4. pygpt_net/controller/chat/attachment.py +5 -1
  5. pygpt_net/controller/chat/image.py +40 -5
  6. pygpt_net/controller/files/files.py +3 -1
  7. pygpt_net/controller/layout/layout.py +2 -2
  8. pygpt_net/controller/media/media.py +70 -1
  9. pygpt_net/controller/theme/nodes.py +2 -1
  10. pygpt_net/controller/ui/mode.py +5 -1
  11. pygpt_net/controller/ui/ui.py +17 -2
  12. pygpt_net/core/filesystem/url.py +4 -1
  13. pygpt_net/core/render/web/helpers.py +5 -0
  14. pygpt_net/data/config/config.json +5 -4
  15. pygpt_net/data/config/models.json +3 -3
  16. pygpt_net/data/config/settings.json +0 -14
  17. pygpt_net/data/css/web-blocks.css +3 -0
  18. pygpt_net/data/css/web-chatgpt.css +3 -0
  19. pygpt_net/data/locale/locale.de.ini +6 -0
  20. pygpt_net/data/locale/locale.en.ini +7 -1
  21. pygpt_net/data/locale/locale.es.ini +6 -0
  22. pygpt_net/data/locale/locale.fr.ini +6 -0
  23. pygpt_net/data/locale/locale.it.ini +6 -0
  24. pygpt_net/data/locale/locale.pl.ini +7 -1
  25. pygpt_net/data/locale/locale.uk.ini +6 -0
  26. pygpt_net/data/locale/locale.zh.ini +6 -0
  27. pygpt_net/launcher.py +115 -55
  28. pygpt_net/preload.py +243 -0
  29. pygpt_net/provider/api/google/image.py +317 -10
  30. pygpt_net/provider/api/google/video.py +160 -4
  31. pygpt_net/provider/api/openai/image.py +201 -93
  32. pygpt_net/provider/api/openai/video.py +99 -24
  33. pygpt_net/provider/api/x_ai/image.py +25 -2
  34. pygpt_net/provider/core/config/patch.py +17 -1
  35. pygpt_net/ui/layout/chat/input.py +20 -2
  36. pygpt_net/ui/layout/chat/painter.py +6 -4
  37. pygpt_net/ui/layout/toolbox/image.py +21 -11
  38. pygpt_net/ui/layout/toolbox/raw.py +2 -2
  39. pygpt_net/ui/layout/toolbox/video.py +22 -9
  40. pygpt_net/ui/main.py +84 -3
  41. pygpt_net/ui/widget/dialog/base.py +3 -10
  42. pygpt_net/ui/widget/option/combo.py +119 -1
  43. pygpt_net/ui/widget/textarea/input_extra.py +664 -0
  44. {pygpt_net-2.7.2.dist-info → pygpt_net-2.7.4.dist-info}/METADATA +27 -20
  45. {pygpt_net-2.7.2.dist-info → pygpt_net-2.7.4.dist-info}/RECORD +48 -46
  46. {pygpt_net-2.7.2.dist-info → pygpt_net-2.7.4.dist-info}/LICENSE +0 -0
  47. {pygpt_net-2.7.2.dist-info → pygpt_net-2.7.4.dist-info}/WHEEL +0 -0
  48. {pygpt_net-2.7.2.dist-info → pygpt_net-2.7.4.dist-info}/entry_points.txt +0 -0
@@ -6,7 +6,7 @@
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.03 00:00:00 #
9
+ # Updated Date: 2025.12.31 16:00:00 #
10
10
  # ================================================== #
11
11
 
12
12
  from functools import partial
@@ -26,6 +26,7 @@ from pygpt_net.ui.widget.audio.input_button import AudioInputButton
26
26
  from pygpt_net.ui.widget.element.labels import HelpLabel
27
27
  from pygpt_net.ui.widget.tabs.Input import InputTabs
28
28
  from pygpt_net.ui.widget.textarea.input import ChatInput
29
+ from pygpt_net.ui.widget.textarea.input_extra import ExtraInput
29
30
  from pygpt_net.utils import trans
30
31
 
31
32
 
@@ -55,6 +56,7 @@ class Input:
55
56
  :return: QWidget
56
57
  """
57
58
  input = self.setup_input()
59
+ input_extra = self.setup_input_extra()
58
60
  files = self.setup_attachments()
59
61
  files_uploaded = self.setup_attachments_uploaded()
60
62
  files_ctx = self.setup_attachments_ctx()
@@ -66,6 +68,7 @@ class Input:
66
68
  tabs.addTab(files, trans('attachments.tab'))
67
69
  tabs.addTab(files_uploaded, trans('attachments_uploaded.tab'))
68
70
  tabs.addTab(files_ctx, trans('attachments_uploaded.tab'))
71
+ tabs.addTab(input_extra, trans('input.tab.extra'))
69
72
  tabs.currentChanged.connect(self.update_min_height)
70
73
 
71
74
  tabs.setTabIcon(0, QIcon(":/icons/input.svg"))
@@ -97,6 +100,21 @@ class Input:
97
100
  layout.setContentsMargins(0, 0, 0, 0)
98
101
  return widget
99
102
 
103
+ def setup_input_extra(self) -> QWidget:
104
+ """
105
+ Setup input tab (extra)
106
+
107
+ :return: QWidget
108
+ """
109
+ self.window.ui.nodes['input_extra'] = ExtraInput(self.window)
110
+ self.window.ui.nodes['input_extra'].setMinimumHeight(self.min_height_input)
111
+
112
+ widget = QWidget()
113
+ layout = QVBoxLayout(widget)
114
+ layout.addWidget(self.window.ui.nodes['input_extra'])
115
+ layout.setContentsMargins(0, 0, 0, 0)
116
+ return widget
117
+
100
118
  def setup_attachments(self) -> QWidget:
101
119
  """
102
120
  Setup attachments
@@ -245,7 +263,7 @@ class Input:
245
263
  controller_ui = self.window.controller.ui
246
264
 
247
265
  idx = tabs.currentIndex()
248
- if idx == 0:
266
+ if idx == 0 or idx == 4:
249
267
  # nodes['input'].setMinimumHeight(self.min_height_input)
250
268
  tabs.setMinimumHeight(self.min_height_input_tab)
251
269
  sizes = controller_ui.splitter_output_size_input
@@ -15,6 +15,7 @@ from PySide6.QtCore import QSize
15
15
 
16
16
  from pygpt_net.ui.widget.draw.painter import PainterWidget
17
17
  from pygpt_net.ui.widget.element.labels import HelpLabel
18
+ from pygpt_net.ui.widget.option.combo import NoScrollCombo
18
19
  from pygpt_net.utils import trans
19
20
 
20
21
 
@@ -57,7 +58,7 @@ class Painter:
57
58
  key = 'painter.select.brush.size'
58
59
  if nodes.get(key) is None:
59
60
  sizes = common.get_sizes()
60
- cb = QComboBox()
61
+ cb = NoScrollCombo(self.window)
61
62
  cb.addItems(sizes)
62
63
  cb.currentTextChanged.connect(common.change_brush_size)
63
64
  cb.setMinimumContentsLength(10)
@@ -67,7 +68,7 @@ class Painter:
67
68
  key = 'painter.select.canvas.size'
68
69
  if nodes.get(key) is None:
69
70
  canvas_sizes = common.get_canvas_sizes()
70
- cb = QComboBox()
71
+ cb = NoScrollCombo(self.window)
71
72
  cb.addItems(canvas_sizes)
72
73
  cb.setMinimumContentsLength(20)
73
74
  cb.setSizeAdjustPolicy(QComboBox.AdjustToContents)
@@ -76,7 +77,7 @@ class Painter:
76
77
 
77
78
  key = 'painter.select.brush.color'
78
79
  if nodes.get(key) is None:
79
- cb = QComboBox()
80
+ cb = NoScrollCombo(self.window)
80
81
  cb.setIconSize(QSize(16, 16))
81
82
  colors = common.get_colors()
82
83
  for color_name, color_value in colors.items():
@@ -86,13 +87,14 @@ class Painter:
86
87
  cb.addItem(icon, color_name, color_value)
87
88
  cb.currentTextChanged.connect(common.change_brush_color)
88
89
  cb.setMinimumContentsLength(10)
90
+ cb.setMinimumWidth(205)
89
91
  cb.setSizeAdjustPolicy(QComboBox.AdjustToContents)
90
92
  nodes[key] = cb
91
93
 
92
94
  # Zoom combo (view-only scale) placed to the right of canvas size
93
95
  key = 'painter.select.zoom'
94
96
  if nodes.get(key) is None:
95
- cb = QComboBox()
97
+ cb = NoScrollCombo(self.window)
96
98
  cb.setMinimumContentsLength(8)
97
99
  cb.setSizeAdjustPolicy(QComboBox.AdjustToContents)
98
100
 
@@ -6,13 +6,15 @@
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.12.28 18:00:00 #
9
+ # Updated Date: 2025.12.30 22:00:00 #
10
10
  # ================================================== #
11
11
 
12
- from PySide6.QtWidgets import QVBoxLayout, QWidget
12
+ from PySide6.QtWidgets import QVBoxLayout, QWidget, QHBoxLayout
13
13
 
14
14
  from pygpt_net.ui.widget.option.combo import OptionCombo
15
- from pygpt_net.ui.widget.option.slider import OptionSlider
15
+ from pygpt_net.ui.widget.option.input import OptionInput
16
+ from pygpt_net.ui.widget.option.toggle_label import ToggleLabel
17
+ from pygpt_net.utils import trans
16
18
 
17
19
 
18
20
  class Image:
@@ -33,33 +35,41 @@ class Image:
33
35
  """
34
36
  option = {
35
37
  "type": "int",
36
- "slider": True,
37
38
  "label": "img_variants",
38
39
  "min": 1,
39
40
  "max": 4,
40
- "step": 1,
41
41
  "value": 1,
42
- "multiplier": 1,
43
42
  }
44
43
 
45
44
  ui = self.window.ui
46
45
  conf_global = ui.config['global']
47
46
 
48
- container = QWidget()
47
+ container = QWidget(parent=self.window)
49
48
  ui.nodes['dalle.options'] = container
50
49
 
51
- conf_global['img_variants'] = OptionSlider(self.window, 'global', 'img_variants', option)
50
+ conf_global['img_variants'] = OptionInput(self.window, 'global', 'img_variants', option)
51
+ conf_global['img_variants'].setToolTip(trans("toolbox.img_variants.label"))
52
52
 
53
53
  option_resolutions = self.window.core.image.get_resolution_option()
54
54
  conf_global['img_resolution'] = OptionCombo(self.window, 'global', 'img_resolution', option_resolutions)
55
55
  conf_global['img_resolution'].setMinimumWidth(160)
56
56
 
57
+ conf_global['img.remix'] = ToggleLabel(trans("img.remix"), parent=self.window)
58
+ conf_global['img.remix'].box.setToolTip(trans("img.remix.tooltip"))
59
+ conf_global['img.remix'].box.toggled.connect(self.window.controller.media.toggle_remix_image)
60
+
61
+ cols = QHBoxLayout()
62
+ cols.addWidget(conf_global['img_resolution'], 3)
63
+ cols.addWidget(conf_global['img_variants'], 1)
64
+ cols.setContentsMargins(2, 5, 5, 5)
65
+
57
66
  rows = QVBoxLayout()
58
- rows.addWidget(conf_global['img_variants'])
59
- rows.addWidget(conf_global['img_resolution'])
67
+ rows.addLayout(cols)
68
+ rows.addWidget(conf_global['img.remix'])
60
69
  rows.setContentsMargins(2, 5, 5, 5)
61
70
 
62
71
  container.setLayout(rows)
63
- container.setContentsMargins(2, 0, 0, 0)
72
+ container.setContentsMargins(2, 0, 0, 10)
73
+ container.setFixedHeight(100)
64
74
 
65
75
  return container
@@ -6,7 +6,7 @@
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.12.25 20:00:00 #
9
+ # Updated Date: 2025.12.30 22:00:00 #
10
10
  # ================================================== #
11
11
 
12
12
  from PySide6.QtWidgets import QVBoxLayout, QHBoxLayout, QWidget, QCheckBox
@@ -34,7 +34,7 @@ class Raw:
34
34
  ui = self.window.ui
35
35
  conf_global = ui.config['global']
36
36
 
37
- container = QWidget()
37
+ container = QWidget(parent=self.window)
38
38
  ui.nodes['media.raw'] = container
39
39
 
40
40
  conf_global['img_raw'] = QCheckBox(trans("img.raw"), parent=container)
@@ -6,13 +6,15 @@
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.12.28 18:00:00 #
9
+ # Updated Date: 2025.12.30 22:00:00 #
10
10
  # ================================================== #
11
11
 
12
- from PySide6.QtWidgets import QWidget, QHBoxLayout
12
+ from PySide6.QtWidgets import QWidget, QHBoxLayout, QVBoxLayout
13
13
 
14
14
  from pygpt_net.ui.widget.option.combo import OptionCombo
15
15
  from pygpt_net.ui.widget.option.input import OptionInput
16
+ from pygpt_net.ui.widget.option.toggle_label import ToggleLabel
17
+ from pygpt_net.utils import trans
16
18
 
17
19
 
18
20
  class Video:
@@ -34,7 +36,7 @@ class Video:
34
36
  ui = self.window.ui
35
37
  conf_global = ui.config['global']
36
38
 
37
- container = QWidget()
39
+ container = QWidget(parent=self.window)
38
40
  ui.nodes['video.options'] = container
39
41
 
40
42
  option_ratio = self.window.core.video.get_aspect_ratio_option()
@@ -44,18 +46,29 @@ class Video:
44
46
  conf_global['video.aspect_ratio'] = OptionCombo(self.window, 'global', 'video.aspect_ratio', option_ratio)
45
47
  conf_global['video.resolution'] = OptionCombo(self.window, 'global', 'video.resolution', option_resolution)
46
48
  conf_global['video.duration'] = OptionInput(self.window, 'global', 'video.duration', option_duration)
49
+ conf_global['video.duration'].setToolTip(trans('settings.video.duration.desc'))
47
50
 
48
51
  conf_global['video.aspect_ratio'].setMinimumWidth(120)
49
52
  conf_global['video.resolution'].setMinimumWidth(120)
50
53
  conf_global['video.duration'].setMinimumWidth(50)
51
54
 
52
- rows = QHBoxLayout()
53
- rows.addWidget(conf_global['video.resolution'], 2)
54
- rows.addWidget(conf_global['video.aspect_ratio'], 2)
55
- rows.addWidget(conf_global['video.duration'], 1)
56
- rows.setContentsMargins(2, 5, 5, 5)
55
+ conf_global['video.remix'] = ToggleLabel(trans("video.remix"), parent=self.window)
56
+ conf_global['video.remix'].box.setToolTip(trans("video.remix.tooltip"))
57
+ conf_global['video.remix'].box.toggled.connect(self.window.controller.media.toggle_remix_video)
58
+
59
+ cols = QHBoxLayout()
60
+ cols.addWidget(conf_global['video.resolution'], 2)
61
+ cols.addWidget(conf_global['video.aspect_ratio'], 2)
62
+ cols.addWidget(conf_global['video.duration'], 1)
63
+ cols.setContentsMargins(2, 5, 5, 5)
64
+
65
+ rows = QVBoxLayout()
66
+ rows.addLayout(cols)
67
+ rows.addWidget(conf_global['video.remix'])
68
+ rows.setContentsMargins(0, 0, 0, 0)
57
69
 
58
70
  container.setLayout(rows)
59
- container.setContentsMargins(2, 0, 0, 0)
71
+ container.setContentsMargins(2, 0, 0, 10)
72
+ container.setFixedHeight(90)
60
73
 
61
74
  return container
pygpt_net/ui/main.py CHANGED
@@ -6,13 +6,13 @@
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.12.28 00:00:00 #
9
+ # Updated Date: 2025.12.31 14:00:00 #
10
10
  # ================================================== #
11
11
 
12
12
  import os
13
13
 
14
14
  from PySide6.QtCore import QTimer, Signal, Slot, QThreadPool, QEvent, Qt, QLoggingCategory, QEventLoop
15
- from PySide6.QtGui import QShortcut, QKeySequence
15
+ from PySide6.QtGui import QShortcut, QKeySequence, QKeyEvent
16
16
  from PySide6.QtWidgets import QMainWindow, QApplication
17
17
  from qt_material import QtStyleTools
18
18
 
@@ -37,6 +37,7 @@ class MainWindow(QMainWindow, QtStyleTools):
37
37
  stateChanged = Signal(str)
38
38
  logger_message = Signal(object)
39
39
  idx_logger_message = Signal(object)
40
+ appReady = Signal() # emitted after the first paint to indicate the window is ready on screen
40
41
 
41
42
  def __init__(self, app: QApplication, args: dict = None):
42
43
  """
@@ -61,6 +62,9 @@ class MainWindow(QMainWindow, QtStyleTools):
61
62
  self.prevState = None
62
63
  self.is_post_update = False
63
64
 
65
+ # app ready emission control
66
+ self._app_ready_emitted = False # ensures single-shot emission
67
+
64
68
  # load version info
65
69
  self.meta = get_app_meta()
66
70
 
@@ -92,6 +96,7 @@ class MainWindow(QMainWindow, QtStyleTools):
92
96
 
93
97
  # global shortcuts
94
98
  self.shortcuts = []
99
+ self._esc_shortcut = None # keep a direct handle to temporarily disable during rerouting
95
100
 
96
101
  # setup signals
97
102
  self.statusChanged.connect(self.update_status)
@@ -231,6 +236,18 @@ class MainWindow(QMainWindow, QtStyleTools):
231
236
  super().showEvent(e)
232
237
  QTimer.singleShot(0, self.ui.on_show)
233
238
 
239
+ def paintEvent(self, e):
240
+ """
241
+ On the first paint, announce that the window is actually visible and ready.
242
+ This is used to synchronize closing the external splash screen.
243
+ """
244
+ super().paintEvent(e)
245
+ if not self._app_ready_emitted:
246
+ self._app_ready_emitted = True
247
+ QTimer.singleShot(0, self.appReady.emit)
248
+ # set focus to main window after shown
249
+ QTimer.singleShot(0, self.activateWindow)
250
+
234
251
  def update(self):
235
252
  """Called on every update (real-time)"""
236
253
  # self.controller.on_update()
@@ -404,6 +421,67 @@ class MainWindow(QMainWindow, QtStyleTools):
404
421
  self.activateWindow()
405
422
  self.ui.tray_menu['restore'].setVisible(False)
406
423
 
424
+ # ----- Global ESC routing that preserves widget-level ESC handling -----
425
+
426
+ def _deliver_escape_to(self, target) -> bool:
427
+ """
428
+ Synthesize ESC keypress to the focused/popup widget so it can run its own close logic.
429
+ Temporarily disables the global ESC shortcut to avoid re-triggering itself.
430
+ """
431
+ if target is None or not target.isVisible():
432
+ return False
433
+ try:
434
+ if self._esc_shortcut is not None:
435
+ self._esc_shortcut.setEnabled(False)
436
+ press = QKeyEvent(QEvent.KeyPress, Qt.Key_Escape, Qt.NoModifier)
437
+ release = QKeyEvent(QEvent.KeyRelease, Qt.Key_Escape, Qt.NoModifier)
438
+ QApplication.sendEvent(target, press)
439
+ QApplication.sendEvent(target, release)
440
+ except Exception:
441
+ pass
442
+ finally:
443
+ if self._esc_shortcut is not None:
444
+ QTimer.singleShot(0, lambda: self._esc_shortcut.setEnabled(True))
445
+ return True
446
+
447
+ def _route_escape_to_focus_or_popup(self) -> bool:
448
+ """
449
+ Route ESC to the widget that currently owns focus (prefer) or active popup widget.
450
+ Returns True when ESC was delivered to a target.
451
+ """
452
+ popup = QApplication.activePopupWidget()
453
+ if popup is not None and popup.isVisible():
454
+ try:
455
+ fw = QApplication.focusWidget()
456
+ if self._deliver_escape_to(fw):
457
+ return True
458
+ except Exception:
459
+ pass
460
+ return True
461
+
462
+ modal = QApplication.activeModalWidget()
463
+ if modal is not None and modal.isVisible():
464
+ try:
465
+ modal.close()
466
+ except Exception:
467
+ pass
468
+ return True
469
+
470
+ # No popup or modal to close
471
+ return False
472
+
473
+ def _on_escape_shortcut(self):
474
+ """
475
+ Global ESC: deliver ESC to the focused/popup widget first so it can handle and cleanup correctly.
476
+ If nothing handles it, run the app-level escape handler.
477
+ """
478
+ if self._route_escape_to_focus_or_popup():
479
+ return
480
+ try:
481
+ self.controller.access.on_escape()
482
+ except Exception:
483
+ pass
484
+
407
485
  def setup_global_shortcuts(self):
408
486
  """Setup global shortcuts"""
409
487
  if not hasattr(self, 'core') or not hasattr(self.core, 'config'):
@@ -417,12 +495,15 @@ class MainWindow(QMainWindow, QtStyleTools):
417
495
  self.shortcuts.clear()
418
496
  else:
419
497
  self.shortcuts = []
498
+ self._esc_shortcut = None
420
499
 
421
500
  # Handle the Escape key
422
501
  escape_shortcut = QShortcut(QKeySequence(Qt.Key_Escape), self)
423
502
  escape_shortcut.setContext(Qt.ApplicationShortcut)
424
- escape_shortcut.activated.connect(self.controller.access.on_escape)
503
+ # escape_shortcut.setAutoRepeat(False) # avoid spamming when holding the key
504
+ escape_shortcut.activated.connect(self._on_escape_shortcut)
425
505
  self.shortcuts.append(escape_shortcut)
506
+ self._esc_shortcut = escape_shortcut
426
507
 
427
508
  config = self.core.config.get("access.shortcuts")
428
509
  if config is None:
@@ -6,7 +6,7 @@
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.08.24 23:00:00 #
9
+ # Updated Date: 2025.12.31 14:00:00 #
10
10
  # ================================================== #
11
11
 
12
12
  from PySide6.QtWidgets import QApplication
@@ -75,9 +75,8 @@ class BaseDialog(QDialog):
75
75
  "size": [self.size().width(), self.size().height()],
76
76
  "pos": [self.pos().x(), self.pos().y()]
77
77
  }
78
+ data = {}
78
79
  if self.store_geometry_enabled():
79
- data = config.get("layout.dialog.geometry", {})
80
- else:
81
80
  data = config.get_session("layout.dialog.geometry", {})
82
81
 
83
82
  if not isinstance(data, dict):
@@ -85,8 +84,6 @@ class BaseDialog(QDialog):
85
84
  data[self.id] = item
86
85
 
87
86
  if self.store_geometry_enabled():
88
- config.set("layout.dialog.geometry", data)
89
- else:
90
87
  config.set_session("layout.dialog.geometry", data)
91
88
 
92
89
  def restore_geometry(self):
@@ -96,14 +93,10 @@ class BaseDialog(QDialog):
96
93
  available_geometry = screen.availableGeometry()
97
94
  config = self.window.core.config
98
95
 
96
+ data = {}
99
97
  if self.store_geometry_enabled():
100
- data = config.get("layout.dialog.geometry", {})
101
- else:
102
98
  data = config.get_session("layout.dialog.geometry", {})
103
99
 
104
- if not isinstance(data, dict):
105
- data = {}
106
-
107
100
  item = data.get(self.id, {})
108
101
  if isinstance(item, dict) and "size" in item and "pos" in item:
109
102
  width, height = item["size"]
@@ -8,6 +8,7 @@
8
8
  # Created By : Marcin Szczygliński #
9
9
  # Updated Date: 2025.12.28 00:00:00 #
10
10
  # ================================================== #
11
+ import sys
11
12
 
12
13
  from PySide6.QtCore import Qt, QEvent, QTimer, QRect, Property
13
14
  from PySide6.QtWidgets import (
@@ -21,12 +22,13 @@ from PySide6.QtWidgets import (
21
22
  QListView,
22
23
  QStyledItemDelegate,
23
24
  QStyleOptionViewItem,
25
+ QApplication,
24
26
  )
25
27
  from PySide6.QtGui import (
26
28
  QFontMetrics,
27
29
  QStandardItem,
28
30
  QStandardItemModel,
29
- QIcon, # keep existing imports, extend with items
31
+ QIcon,
30
32
  QColor,
31
33
  QPainter,
32
34
  QPen,
@@ -399,6 +401,9 @@ class SearchableCombo(SeparatorComboBox):
399
401
  self._swallow_release_once: bool = False # kept for compatibility; not used in the new flow
400
402
  self._open_on_release: bool = False # open popup on mouse release (non-arrow path)
401
403
 
404
+ # One-shot guard to ignore replayed click when user closes popup by clicking this same combo
405
+ self._prevent_reopen_once: bool = False
406
+
402
407
  # Popup fitting helpers
403
408
  self._fit_in_progress: bool = False
404
409
  self._popup_parent_window = None
@@ -508,6 +513,11 @@ class SearchableCombo(SeparatorComboBox):
508
513
  QTimer.singleShot(0, self._fit_popup_to_window)
509
514
  self._refresh_popup_view()
510
515
 
516
+ # Minimal robustness: re-ensure header after popup is fully shown (helps on Windows when switching combos)
517
+ QTimer.singleShot(0, self._ensure_header_after_show)
518
+ QTimer.singleShot(15, self._ensure_header_after_show)
519
+ QTimer.singleShot(60, self._ensure_header_after_show)
520
+
511
521
  def hidePopup(self):
512
522
  """Close popup and restore normal display text; remove header/margins."""
513
523
  super().hidePopup()
@@ -673,6 +683,12 @@ class SearchableCombo(SeparatorComboBox):
673
683
  Use release-to-open on the non-arrow area to avoid immediate close when the popup opens upward.
674
684
  Keep the arrow area with the default toggle behaviour from the base class.
675
685
  """
686
+ # One-shot guard: if popup was closed by clicking this same combo, ignore the replayed click
687
+ if event.button() == Qt.LeftButton and self._prevent_reopen_once:
688
+ self._prevent_reopen_once = False
689
+ event.accept()
690
+ return
691
+
676
692
  if event.button() == Qt.LeftButton and self.isEnabled():
677
693
  arrow_rect = self._arrow_rect()
678
694
  if arrow_rect.contains(event.pos()):
@@ -722,6 +738,8 @@ class SearchableCombo(SeparatorComboBox):
722
738
  event.accept()
723
739
  return
724
740
  if event.key() == Qt.Key_Escape:
741
+ # Explicitly close so the next click opens immediately
742
+ self.hidePopup()
725
743
  event.accept()
726
744
  return
727
745
  super().keyPressEvent(event)
@@ -737,6 +755,53 @@ class SearchableCombo(SeparatorComboBox):
737
755
  - Keep popup horizontally inside the parent window while resizing/moving.
738
756
  """
739
757
  if obj is self._popup_container and self._popup_container is not None:
758
+ # Open target combo immediately when user clicks outside current popup
759
+ if event.type() == QEvent.MouseButtonPress:
760
+ try:
761
+ # Detect left-click outside popup window
762
+ left_btn = (getattr(event, "button", lambda: None)() == Qt.LeftButton)
763
+ pos_local = getattr(event, "position", lambda: None)()
764
+ inside = self._popup_container.rect().contains(pos_local.toPoint()) if pos_local is not None else True
765
+ if left_btn and not inside:
766
+ # Find widget under cursor and see if it's a different combo
767
+ gp = getattr(event, "globalPosition", None)
768
+ gp_pt = gp().toPoint() if callable(gp) else getattr(event, "globalPos", lambda: None)()
769
+ if gp_pt is None:
770
+ gp_pt = QApplication.instance().cursor().pos() if QApplication.instance() else None
771
+ target = QApplication.widgetAt(gp_pt) if gp_pt is not None else None
772
+ target_combo = self._ascend_to_combo(target)
773
+ if target_combo is self:
774
+ # Clicked back on the owner combo: arm one-shot guard to suppress reopen
775
+ self._prevent_reopen_once = True
776
+ elif isinstance(target_combo, SearchableCombo) and target_combo.isEnabled():
777
+ def _open_other():
778
+ if target_combo and target_combo.isEnabled():
779
+ try:
780
+ target_combo.setFocus(Qt.MouseFocusReason)
781
+ except Exception:
782
+ pass
783
+ target_combo.showPopup()
784
+ if sys.platform != "win32":
785
+ QTimer.singleShot(0, _open_other)
786
+ else:
787
+ container = self._popup_container
788
+ opened_on_destroy = False
789
+ try:
790
+ if container is not None:
791
+ container.destroyed.connect(lambda *_: QTimer.singleShot(0, _open_other))
792
+ opened_on_destroy = True
793
+ except Exception:
794
+ pass
795
+ if not opened_on_destroy:
796
+ QTimer.singleShot(50, _open_other)
797
+ try:
798
+ self.hidePopup()
799
+ except Exception:
800
+ pass
801
+ except Exception:
802
+ pass
803
+ return False
804
+
740
805
  if event.type() in (QEvent.Resize, QEvent.Show):
741
806
  self._place_popup_header()
742
807
  # Also ensure fitting after container geometry changes
@@ -753,6 +818,8 @@ class SearchableCombo(SeparatorComboBox):
753
818
  if event.type() == QEvent.KeyPress:
754
819
  key = event.key()
755
820
  if key == Qt.Key_Escape:
821
+ # Explicitly close popup from header
822
+ self.hidePopup()
756
823
  return True
757
824
  if key in (Qt.Key_Up, Qt.Key_Down, Qt.Key_PageUp, Qt.Key_PageDown, Qt.Key_Home, Qt.Key_End):
758
825
  self._handle_navigation_key(key)
@@ -769,11 +836,24 @@ class SearchableCombo(SeparatorComboBox):
769
836
  self._commit_view_current()
770
837
  return True
771
838
  if event.key() == Qt.Key_Escape:
839
+ # Close from view/viewport ESC and keep control flow consistent
840
+ self.hidePopup()
772
841
  return True
773
842
  return False
774
843
 
775
844
  return super().eventFilter(obj, event)
776
845
 
846
+ # Helpers to walk up to owning combo under a point
847
+ def _ascend_to_combo(self, widget):
848
+ """Ascend from a widget to the nearest SearchableCombo owner if present."""
849
+ w = widget
850
+ while w is not None and not isinstance(w, SearchableCombo):
851
+ try:
852
+ w = w.parentWidget()
853
+ except Exception:
854
+ return None
855
+ return w
856
+
777
857
  # ----- Magnifier helpers -----
778
858
 
779
859
  def _ensure_magnifier_on(self, line_edit: QLineEdit | None):
@@ -938,6 +1018,44 @@ class SearchableCombo(SeparatorComboBox):
938
1018
  self._popup_header.setCursorPosition(len(self._popup_header.text()))
939
1019
  self._ensure_clear_button_visible(self._popup_header)
940
1020
 
1021
+ # ----- Minimal ensure after show (mainly for Windows combo-to-combo switch) -----
1022
+
1023
+ def _ensure_header_after_show(self):
1024
+ """
1025
+ Re-apply critical bits after the popup is up:
1026
+ - header exists and is parented to the popup container,
1027
+ - viewport top margin equals header height,
1028
+ - header is placed and focused.
1029
+ """
1030
+ if not self._popup_open or not self.search:
1031
+ return
1032
+ view = self.view()
1033
+ if view is None:
1034
+ return
1035
+ container = self._popup_container or view.window()
1036
+ if container is None:
1037
+ return
1038
+
1039
+ if self._popup_header is None:
1040
+ self._prepare_popup_header()
1041
+ return
1042
+
1043
+ try:
1044
+ if self._popup_header.parent() is not container:
1045
+ self._popup_header.setParent(container)
1046
+ except Exception:
1047
+ pass
1048
+
1049
+ try:
1050
+ view.setViewportMargins(0, self._popup_header_h, 0, 0)
1051
+ except Exception:
1052
+ pass
1053
+
1054
+ self._place_popup_header()
1055
+ self._ensure_magnifier_on(self._popup_header)
1056
+ self._ensure_clear_button_visible(self._popup_header)
1057
+ self._focus_header_async()
1058
+
941
1059
  # ----- Search behaviour -----
942
1060
 
943
1061
  def _on_search_text_changed(self, text: str):