pygpt-net 2.6.60__py3-none-any.whl → 2.6.62__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 (87) hide show
  1. pygpt_net/CHANGELOG.txt +14 -0
  2. pygpt_net/__init__.py +3 -3
  3. pygpt_net/controller/chat/common.py +115 -6
  4. pygpt_net/controller/chat/input.py +4 -1
  5. pygpt_net/controller/chat/response.py +8 -2
  6. pygpt_net/controller/presets/presets.py +121 -6
  7. pygpt_net/controller/settings/editor.py +0 -15
  8. pygpt_net/controller/settings/profile.py +16 -4
  9. pygpt_net/controller/settings/workdir.py +30 -5
  10. pygpt_net/controller/theme/common.py +4 -2
  11. pygpt_net/controller/theme/markdown.py +4 -7
  12. pygpt_net/controller/theme/theme.py +2 -1
  13. pygpt_net/controller/ui/ui.py +32 -7
  14. pygpt_net/core/agents/custom/__init__.py +7 -1
  15. pygpt_net/core/agents/custom/llama_index/factory.py +17 -6
  16. pygpt_net/core/agents/custom/llama_index/runner.py +52 -4
  17. pygpt_net/core/agents/custom/llama_index/utils.py +12 -1
  18. pygpt_net/core/agents/custom/router.py +45 -6
  19. pygpt_net/core/agents/custom/runner.py +11 -5
  20. pygpt_net/core/agents/custom/schema.py +3 -1
  21. pygpt_net/core/agents/custom/utils.py +13 -1
  22. pygpt_net/core/agents/runners/llama_workflow.py +65 -5
  23. pygpt_net/core/agents/runners/openai_workflow.py +2 -1
  24. pygpt_net/core/db/viewer.py +11 -5
  25. pygpt_net/core/node_editor/graph.py +18 -9
  26. pygpt_net/core/node_editor/models.py +9 -2
  27. pygpt_net/core/node_editor/types.py +15 -1
  28. pygpt_net/core/presets/presets.py +216 -29
  29. pygpt_net/core/render/markdown/parser.py +0 -2
  30. pygpt_net/core/render/web/renderer.py +76 -11
  31. pygpt_net/data/config/config.json +5 -6
  32. pygpt_net/data/config/models.json +3 -3
  33. pygpt_net/data/config/settings.json +2 -38
  34. pygpt_net/data/css/style.dark.css +18 -0
  35. pygpt_net/data/css/style.light.css +20 -1
  36. pygpt_net/data/locale/locale.de.ini +66 -1
  37. pygpt_net/data/locale/locale.en.ini +64 -3
  38. pygpt_net/data/locale/locale.es.ini +66 -1
  39. pygpt_net/data/locale/locale.fr.ini +66 -1
  40. pygpt_net/data/locale/locale.it.ini +66 -1
  41. pygpt_net/data/locale/locale.pl.ini +67 -2
  42. pygpt_net/data/locale/locale.uk.ini +66 -1
  43. pygpt_net/data/locale/locale.zh.ini +66 -1
  44. pygpt_net/data/locale/plugin.cmd_system.en.ini +62 -66
  45. pygpt_net/item/ctx.py +23 -1
  46. pygpt_net/provider/agents/llama_index/flow_from_schema.py +2 -2
  47. pygpt_net/provider/agents/llama_index/workflow/codeact.py +9 -6
  48. pygpt_net/provider/agents/llama_index/workflow/openai.py +38 -11
  49. pygpt_net/provider/agents/llama_index/workflow/planner.py +36 -16
  50. pygpt_net/provider/agents/llama_index/workflow/supervisor.py +60 -10
  51. pygpt_net/provider/agents/openai/agent.py +3 -1
  52. pygpt_net/provider/agents/openai/agent_b2b.py +13 -9
  53. pygpt_net/provider/agents/openai/agent_planner.py +6 -2
  54. pygpt_net/provider/agents/openai/agent_with_experts.py +4 -1
  55. pygpt_net/provider/agents/openai/agent_with_experts_feedback.py +4 -2
  56. pygpt_net/provider/agents/openai/agent_with_feedback.py +4 -2
  57. pygpt_net/provider/agents/openai/evolve.py +6 -2
  58. pygpt_net/provider/agents/openai/supervisor.py +3 -1
  59. pygpt_net/provider/api/openai/agents/response.py +1 -0
  60. pygpt_net/provider/core/config/patch.py +18 -1
  61. pygpt_net/provider/core/config/patches/patch_before_2_6_42.py +0 -6
  62. pygpt_net/tools/agent_builder/tool.py +48 -26
  63. pygpt_net/tools/agent_builder/ui/dialogs.py +36 -28
  64. pygpt_net/ui/__init__.py +2 -4
  65. pygpt_net/ui/dialog/about.py +58 -38
  66. pygpt_net/ui/dialog/db.py +142 -3
  67. pygpt_net/ui/dialog/preset.py +47 -8
  68. pygpt_net/ui/layout/toolbox/presets.py +64 -16
  69. pygpt_net/ui/main.py +2 -2
  70. pygpt_net/ui/widget/dialog/confirm.py +27 -3
  71. pygpt_net/ui/widget/dialog/db.py +0 -0
  72. pygpt_net/ui/widget/draw/painter.py +90 -1
  73. pygpt_net/ui/widget/lists/preset.py +908 -60
  74. pygpt_net/ui/widget/node_editor/command.py +10 -10
  75. pygpt_net/ui/widget/node_editor/config.py +157 -0
  76. pygpt_net/ui/widget/node_editor/editor.py +223 -153
  77. pygpt_net/ui/widget/node_editor/item.py +12 -11
  78. pygpt_net/ui/widget/node_editor/node.py +246 -13
  79. pygpt_net/ui/widget/node_editor/view.py +179 -63
  80. pygpt_net/ui/widget/tabs/output.py +1 -1
  81. pygpt_net/ui/widget/textarea/input.py +157 -23
  82. pygpt_net/utils.py +114 -2
  83. {pygpt_net-2.6.60.dist-info → pygpt_net-2.6.62.dist-info}/METADATA +26 -100
  84. {pygpt_net-2.6.60.dist-info → pygpt_net-2.6.62.dist-info}/RECORD +86 -85
  85. {pygpt_net-2.6.60.dist-info → pygpt_net-2.6.62.dist-info}/LICENSE +0 -0
  86. {pygpt_net-2.6.60.dist-info → pygpt_net-2.6.62.dist-info}/WHEEL +0 -0
  87. {pygpt_net-2.6.60.dist-info → pygpt_net-2.6.62.dist-info}/entry_points.txt +0 -0
@@ -6,12 +6,12 @@
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.09.26 12:00:00 #
10
10
  # ================================================== #
11
11
 
12
- from PySide6.QtCore import QPoint, QItemSelectionModel
13
- from PySide6.QtGui import QAction, QIcon, Qt
14
- from PySide6.QtWidgets import QMenu
12
+ from PySide6.QtCore import QPoint, QItemSelectionModel, Qt, QEventLoop, QTimer, QMimeData
13
+ from PySide6.QtGui import QAction, QIcon, QCursor, QDrag, QPainter, QPixmap, QPen, QColor
14
+ from PySide6.QtWidgets import QMenu, QAbstractItemView, QApplication
15
15
 
16
16
  from pygpt_net.core.types import (
17
17
  MODE_EXPERT,
@@ -27,6 +27,12 @@ class PresetList(BaseList):
27
27
  _ICO_DELETE = QIcon(":/icons/delete.svg")
28
28
  _ICO_CHECK = QIcon(":/icons/check.svg")
29
29
  _ICO_CLOSE = QIcon(":/icons/close.svg")
30
+ _ICO_UP = QIcon(":/icons/collapse.svg")
31
+ _ICO_DOWN = QIcon(":/icons/expand.svg")
32
+
33
+ ROLE_UUID = Qt.UserRole + 1
34
+ ROLE_ID = Qt.UserRole + 2
35
+ ROLE_IS_SPECIAL = Qt.UserRole + 3
30
36
 
31
37
  def __init__(self, window=None, id=None):
32
38
  """
@@ -45,40 +51,306 @@ class PresetList(BaseList):
45
51
  self._backup_selection = None
46
52
  self.restore_after_ctx_menu = True
47
53
 
54
+ # Flat list behavior
55
+ self.setRootIsDecorated(False)
56
+ self.setItemsExpandable(False)
57
+ self.setUniformRowHeights(True)
58
+ self.setSelectionBehavior(QAbstractItemView.SelectRows)
59
+ self.setSelectionMode(QAbstractItemView.SingleSelection)
60
+
61
+ # Drag & drop state
62
+ self._dnd_enabled = False
63
+ self.setDragEnabled(False)
64
+ self.setAcceptDrops(False)
65
+ self.setDragDropMode(QAbstractItemView.NoDragDrop) # switched dynamically
66
+ self.setDefaultDropAction(Qt.MoveAction)
67
+ self.setDragDropOverwriteMode(False)
68
+ # We use our own visual indicator for drop position
69
+ self.setDropIndicatorShown(False)
70
+
71
+ self._press_pos = None
72
+ self._press_index = None
73
+ self._press_backup_selection = None
74
+ self._press_backup_current = None
75
+ self._dragging = False
76
+ self._dragged_was_selected = False
77
+
78
+ # Mark that we already applied selection at drag start (one-shot per DnD)
79
+ self._drag_selection_applied = False
80
+
81
+ # ID-based selection persistence (single selection list)
82
+ self._saved_selection_ids = None
83
+
84
+ # Defer refresh payload after drop (DnD teardown must finish first)
85
+ self._pending_after_drop = None
86
+
87
+ # Guard against input during model rebuild (prevents crashes on quick clicks)
88
+ self._model_updating = False
89
+
90
+ # Keep original selection IDs before opening context menu (right-click)
91
+ self._ctx_menu_original_ids = None
92
+
93
+ # One-shot forced selection after refresh (list of ROLE_ID)
94
+ self._selection_override_ids = None
95
+
96
+ # Custom drop indicator (visual only)
97
+ self._drop_indicator_active = False
98
+ # seam row for indicator (row under which the line is drawn)
99
+ self._drop_indicator_to_row = -1
100
+ self._drop_indicator_padding = 6 # visual left/right padding
101
+
102
+ # Short-lived scroll freeze to prevent jumps during click-triggered model refresh
103
+ self._scroll_freeze_depth = 0
104
+ self._scroll_freeze_timer = None
105
+ self._pending_scroll_value = None
106
+ self._pending_refocus_role_id = None
107
+
108
+ # -------- Public helpers to protect updates --------
109
+
110
+ def begin_model_update(self):
111
+ """Temporarily block user interaction while the model/view is rebuilt."""
112
+ self._model_updating = True
113
+ self.setEnabled(False)
114
+
115
+ def end_model_update(self):
116
+ """Re-enable interaction after model/view rebuild is complete."""
117
+ self.setEnabled(True)
118
+ self._model_updating = False
119
+ # If there is a pending scroll/selection stabilization, apply it right after update
120
+ self._apply_pending_scroll()
121
+ self._apply_pending_refocus()
122
+ QTimer.singleShot(0, self._apply_pending_scroll)
123
+ QTimer.singleShot(0, self._apply_pending_refocus)
124
+ # Unfreeze shortly after everything settled in the event loop
125
+ QTimer.singleShot(50, self._unfreeze_scroll)
126
+
127
+ # ---------------------------------------------------
128
+
129
+ # -------- Scroll freeze helpers (prevent accidental jumps on click) --------
130
+
131
+ def _freeze_scroll(self, ms: int = 250):
132
+ """
133
+ Freeze scrollTo() effects for a very short time and keep current scroll value.
134
+ This avoids jumps caused by programmatic scroll during selection/refresh.
135
+ """
136
+ try:
137
+ sb = self.verticalScrollBar()
138
+ except Exception:
139
+ sb = None
140
+ if sb is not None:
141
+ self._pending_scroll_value = sb.value()
142
+ self._scroll_freeze_depth += 1
143
+
144
+ # Apply stabilization now and on next frame(s)
145
+ QTimer.singleShot(0, self._apply_pending_scroll)
146
+ QTimer.singleShot(16, self._apply_pending_scroll)
147
+
148
+ # Auto-unfreeze after given duration
149
+ if self._scroll_freeze_timer:
150
+ try:
151
+ self._scroll_freeze_timer.stop()
152
+ except Exception:
153
+ pass
154
+ self._scroll_freeze_timer = QTimer(self)
155
+ self._scroll_freeze_timer.setSingleShot(True)
156
+ self._scroll_freeze_timer.timeout.connect(self._unfreeze_scroll)
157
+ self._scroll_freeze_timer.start(max(50, int(ms)))
158
+
159
+ def _apply_pending_scroll(self):
160
+ """Re-apply saved scroll position when frozen."""
161
+ if self._pending_scroll_value is None:
162
+ return
163
+ try:
164
+ sb = self.verticalScrollBar()
165
+ except Exception:
166
+ sb = None
167
+ if sb is not None:
168
+ sb.setValue(self._pending_scroll_value)
169
+
170
+ def _unfreeze_scroll(self):
171
+ """Release the temporary scroll freeze."""
172
+ if self._scroll_freeze_depth > 0:
173
+ self._scroll_freeze_depth -= 1
174
+ if self._scroll_freeze_depth <= 0:
175
+ self._scroll_freeze_depth = 0
176
+ self._pending_scroll_value = None
177
+
178
+ def scrollTo(self, index, hint=QAbstractItemView.EnsureVisible):
179
+ """
180
+ Temporarily suppress automatic scrolling while frozen.
181
+ This prevents list jumping when selection triggers scrollTo during refresh.
182
+ """
183
+ if self._scroll_freeze_depth > 0:
184
+ self._apply_pending_scroll()
185
+ return
186
+ return super().scrollTo(index, hint)
187
+
188
+ def _apply_pending_refocus(self):
189
+ """
190
+ Ensure selection stays on the intended item (by ROLE_ID) after a model refresh.
191
+ Does not force scrolling when scroll is frozen.
192
+ """
193
+ pid = self._pending_refocus_role_id
194
+ if not pid:
195
+ return
196
+ model = self.model()
197
+ if model is None:
198
+ return
199
+ target_idx = None
200
+ try:
201
+ for r in range(model.rowCount()):
202
+ ix = model.index(r, 0)
203
+ if ix.data(self.ROLE_ID) == pid:
204
+ target_idx = ix
205
+ break
206
+ except Exception:
207
+ target_idx = None
208
+
209
+ if target_idx is not None and target_idx.isValid():
210
+ try:
211
+ sel_model = self.selectionModel()
212
+ if sel_model:
213
+ prev_unlocked = getattr(self, "unlocked", True)
214
+ self.unlocked = True
215
+ try:
216
+ sel_model.clearSelection()
217
+ sel_model.select(target_idx, QItemSelectionModel.ClearAndSelect | QItemSelectionModel.Rows)
218
+ self.setCurrentIndex(target_idx)
219
+ finally:
220
+ self.unlocked = prev_unlocked
221
+ # If refocus succeeded, clear the pending marker
222
+ self._pending_refocus_role_id = None
223
+ except Exception:
224
+ # Keep pending id for next attempt if apply failed
225
+ pass
226
+
227
+ # --------------------------------------------------------------------------
228
+
229
+ def set_dnd_enabled(self, enabled: bool):
230
+ """
231
+ Toggle DnD behaviour at runtime.
232
+ Using DragDrop (not InternalMove) to avoid implicit Qt reordering.
233
+ We also disable the native drop indicator and render our own line.
234
+ """
235
+ self._dnd_enabled = bool(enabled)
236
+ if self._dnd_enabled:
237
+ self.setDragEnabled(True)
238
+ self.setAcceptDrops(True)
239
+ self.setDragDropMode(QAbstractItemView.DragDrop)
240
+ self.setDropIndicatorShown(False) # use custom indicator
241
+ else:
242
+ self.setDragEnabled(False)
243
+ self.setAcceptDrops(False)
244
+ self.setDragDropMode(QAbstractItemView.NoDragDrop)
245
+ self.setDropIndicatorShown(False)
246
+ self.unsetCursor()
247
+ self._clear_drop_indicator() # ensure clean state
248
+
249
+ def backup_selection(self):
250
+ """
251
+ Persist selected preset identity (by ROLE_ID) instead of raw indexes.
252
+ """
253
+ try:
254
+ sel_rows = self.selectionModel().selectedRows()
255
+ ids = []
256
+ for ix in sel_rows:
257
+ pid = ix.data(self.ROLE_ID)
258
+ if pid:
259
+ ids.append(str(pid))
260
+ self._saved_selection_ids = ids if ids else None
261
+ except Exception:
262
+ self._saved_selection_ids = None
263
+
264
+ def restore_selection(self):
265
+ """
266
+ Restore selection by ROLE_ID to keep it attached to the same item regardless of position.
267
+ """
268
+ ids = self._saved_selection_ids or []
269
+ self._saved_selection_ids = None
270
+ if not ids:
271
+ return
272
+ model = self.model()
273
+ if model is None:
274
+ return
275
+ target_id = ids[0]
276
+ sel_model = self.selectionModel()
277
+ prev_unlocked = self.unlocked
278
+ self.unlocked = True
279
+ try:
280
+ sel_model.clearSelection()
281
+ first_idx = None
282
+ for r in range(model.rowCount()):
283
+ idx = model.index(r, 0)
284
+ pid = idx.data(self.ROLE_ID)
285
+ if pid == target_id:
286
+ first_idx = idx
287
+ break
288
+ if first_idx is not None and first_idx.isValid():
289
+ sel_model.select(first_idx, QItemSelectionModel.ClearAndSelect | QItemSelectionModel.Rows)
290
+ self.setCurrentIndex(first_idx)
291
+ self.scrollTo(first_idx)
292
+ finally:
293
+ self.unlocked = prev_unlocked
294
+
295
+ def _current_selected_ids(self) -> list[str]:
296
+ """Read current selection IDs (ROLE_ID)."""
297
+ try:
298
+ return [ix.data(self.ROLE_ID) for ix in self.selectionModel().selectedRows() if ix.data(self.ROLE_ID)]
299
+ except Exception:
300
+ return []
301
+
48
302
  def click(self, val):
49
- row = val.row()
303
+ """Row click handler; select by ID (stable under reordering)."""
304
+ if self._model_updating:
305
+ return
306
+ index = val
307
+ if not index.isValid():
308
+ return
309
+ preset_id = index.data(self.ROLE_ID)
310
+ if preset_id:
311
+ # Freeze scroll and remember the intended selection to re-apply after any refresh
312
+ self._freeze_scroll(300)
313
+ self._pending_refocus_role_id = preset_id
314
+ self.window.controller.presets.select_by_id(preset_id)
315
+ # Re-apply selection in next ticks to win races with late refresh
316
+ QTimer.singleShot(0, self._apply_pending_refocus)
317
+ QTimer.singleShot(50, self._apply_pending_refocus)
318
+ self.selection = self.selectionModel().selection()
319
+ return
320
+ row = index.row()
50
321
  if row >= 0:
322
+ self._freeze_scroll(300)
51
323
  self.window.controller.presets.select(row)
324
+ QTimer.singleShot(0, self._apply_pending_refocus)
325
+ QTimer.singleShot(50, self._apply_pending_refocus)
52
326
  self.selection = self.selectionModel().selection()
53
327
 
54
328
  def dblclick(self, val):
55
- """
56
- Double click event
57
-
58
- :param val: double click event
59
- """
329
+ """Double click event"""
330
+ if self._model_updating:
331
+ return
60
332
  row = val.row()
61
333
  if row >= 0:
62
334
  self.window.controller.presets.editor.edit(row)
63
335
 
64
336
  def show_context_menu(self, pos: QPoint):
65
- """
66
- Context menu event
67
-
68
- :param pos: QPoint
69
- """
337
+ """Context menu event"""
338
+ if self._model_updating:
339
+ return
70
340
  global_pos = self.viewport().mapToGlobal(pos)
71
341
  mode = self.window.core.config.get('mode')
72
342
  index = self.indexAt(pos)
73
343
  idx = index.row()
74
344
 
75
345
  preset = None
346
+ preset_id = None
76
347
  if idx >= 0:
77
348
  preset_id = self.window.core.presets.get_by_idx(idx, mode)
78
349
  if preset_id:
79
350
  preset = self.window.core.presets.items.get(preset_id)
80
351
 
81
352
  is_current = idx >= 0 and self.window.controller.presets.is_current(idx)
353
+ is_special = bool(index.data(self.ROLE_IS_SPECIAL)) if index.isValid() else False
82
354
 
83
355
  if idx >= 0:
84
356
  menu = QMenu(self)
@@ -100,6 +372,16 @@ class PresetList(BaseList):
100
372
  duplicate_act = QAction(self._ICO_COPY, trans('preset.action.duplicate'), menu)
101
373
  duplicate_act.triggered.connect(lambda checked=False, it=index: self.action_duplicate(it))
102
374
 
375
+ if self._dnd_enabled and not is_current and not is_special:
376
+ up_act = QAction(self._ICO_UP, trans('common.up'), menu)
377
+ down_act = QAction(self._ICO_DOWN, trans('common.down'), menu)
378
+ up_act.setEnabled(idx > 1)
379
+ down_act.setEnabled(idx < (self.model().rowCount() - 1))
380
+ up_act.triggered.connect(lambda checked=False, it=index: self.action_move_up(it))
381
+ down_act.triggered.connect(lambda checked=False, it=index: self.action_move_down(it))
382
+ menu.addAction(up_act)
383
+ menu.addAction(down_act)
384
+
103
385
  if is_current:
104
386
  edit_act.setEnabled(False)
105
387
  restore_act = QAction(self._ICO_UNDO, trans('dialog.editor.btn.defaults'), menu)
@@ -115,10 +397,7 @@ class PresetList(BaseList):
115
397
  self.selection = self.selectionModel().selection()
116
398
  menu.exec_(global_pos)
117
399
 
118
- # store previous scroll position
119
400
  self.store_scroll_position()
120
-
121
- # restore selection if it was backed up
122
401
  if self.restore_after_ctx_menu:
123
402
  if self._backup_selection is not None:
124
403
  sel_model = self.selectionModel()
@@ -128,82 +407,469 @@ class PresetList(BaseList):
128
407
  i, QItemSelectionModel.Select | QItemSelectionModel.Rows
129
408
  )
130
409
  self._backup_selection = None
131
-
132
- # restore scroll position
133
410
  self.restore_after_ctx_menu = True
134
411
  self.restore_scroll_position()
135
412
 
136
413
  def action_edit(self, item):
137
- """
138
- Edit action handler
139
-
140
- :param item: list item
141
- """
142
414
  idx = item.row()
143
415
  if idx >= 0:
144
- self.restore_after_ctx_menu = False # do not restore selection after context menu
416
+ self.restore_after_ctx_menu = False
145
417
  self.window.controller.presets.editor.edit(idx)
146
418
 
147
419
  def action_duplicate(self, item):
148
- """
149
- Duplicate action handler
150
-
151
- :param item: list item
152
- """
153
420
  idx = item.row()
154
421
  if idx >= 0:
155
- self.restore_after_ctx_menu = False # do not restore selection after context menu
422
+ self.restore_after_ctx_menu = False
156
423
  self.window.controller.presets.duplicate(idx)
157
424
 
158
425
  def action_delete(self, item):
159
- """
160
- Delete action handler
161
-
162
- :param item: list item
163
- """
164
426
  idx = item.row()
165
427
  if idx >= 0:
166
- self.restore_after_ctx_menu = False # do not restore selection after context menu
428
+ self.restore_after_ctx_menu = False
167
429
  self.window.controller.presets.delete(idx)
168
430
 
169
431
  def action_restore(self, item):
170
- """
171
- Restore action handler
172
-
173
- :param item: list item
174
- """
175
432
  self.window.controller.presets.restore()
176
433
 
177
434
  def action_enable(self, item):
178
- """
179
- Enable action handler
180
-
181
- :param item: list item
182
- """
183
435
  idx = item.row()
184
436
  if idx >= 0:
185
437
  self.window.controller.presets.enable(idx)
186
438
 
187
439
  def action_disable(self, item):
188
- """
189
- Disable action handler
190
-
191
- :param item: list item
192
- """
193
440
  idx = item.row()
194
441
  if idx >= 0:
195
442
  self.window.controller.presets.disable(idx)
196
443
 
444
+ def action_move_up(self, item):
445
+ row = item.row()
446
+ if row <= 1:
447
+ return
448
+ self.restore_after_ctx_menu = False
449
+ # Select the moved element (exception rule for context Up)
450
+ moved_role_id = item.data(self.ROLE_ID)
451
+ if moved_role_id:
452
+ self._selection_override_ids = [moved_role_id]
453
+ # Keep controller in sync with the view selection
454
+ self.window.controller.presets.select_by_id(moved_role_id)
455
+ self._move_row(row, row - 1)
456
+
457
+ def action_move_down(self, item):
458
+ row = item.row()
459
+ if row < 0 or row >= (self.model().rowCount() - 1):
460
+ return
461
+ if row == 0:
462
+ return
463
+ self.restore_after_ctx_menu = False
464
+ # Select the moved element (exception rule for context Down)
465
+ moved_role_id = item.data(self.ROLE_ID)
466
+ if moved_role_id:
467
+ self._selection_override_ids = [moved_role_id]
468
+ # Keep controller in sync with the view selection
469
+ self.window.controller.presets.select_by_id(moved_role_id)
470
+ self._move_row(row, row + 1)
471
+
472
+ # ----------------------------
473
+ # Ordering helpers (core-based)
474
+ # ----------------------------
475
+
476
+ def _core_regular_ids_for_mode(self) -> list[str]:
477
+ """Return current ordered preset IDs for mode, excluding pinned current.<mode>."""
478
+ mode = self.window.core.config.get('mode')
479
+ data = self.window.core.presets.get_by_mode(mode) or {}
480
+ ids = list(data.keys())
481
+ if ids and ids[0].startswith("current."):
482
+ ids = ids[1:]
483
+ return ids
484
+
485
+ def _core_regular_uuids_for_mode(self) -> list[str]:
486
+ """UUID list resolved from core ordered IDs (excluding pinned)."""
487
+ ids = self._core_regular_ids_for_mode()
488
+ items = self.window.core.presets.items
489
+ out = []
490
+ for pid in ids:
491
+ it = items.get(pid)
492
+ if it and it.uuid:
493
+ out.append(it.uuid)
494
+ return out
495
+
496
+ def _collect_regular_uuids(self) -> list[str]:
497
+ """Backward-compatible wrapper used by older code: now returns core-based UUIDs."""
498
+ return self._core_regular_uuids_for_mode()
499
+
500
+ def _is_row_selected(self, row: int) -> bool:
501
+ """Check if given row is currently selected."""
502
+ try:
503
+ sel = self.selectionModel().selectedRows()
504
+ return any(ix.row() == row for ix in sel)
505
+ except Exception:
506
+ return False
507
+
508
+ def _reorder_and_persist(self, from_row: int, to_row: int) -> str:
509
+ """
510
+ Compute new UUID order using core order (not the view), then persist it.
511
+ Returns moved preset ID (filename) for later selection if needed.
512
+ """
513
+ if from_row <= 0 or to_row <= 0:
514
+ return ""
515
+
516
+ ids_seq = self._core_regular_ids_for_mode()
517
+ if not ids_seq:
518
+ return ""
519
+
520
+ i_from = from_row - 1
521
+ i_to = to_row - 1
522
+ if i_from < 0 or i_from >= len(ids_seq):
523
+ return ""
524
+ if i_to < 0:
525
+ i_to = 0
526
+ if i_to > len(ids_seq):
527
+ i_to = len(ids_seq)
528
+
529
+ moved_id = ids_seq[i_from]
530
+ seq_ids = list(ids_seq)
531
+ item = seq_ids.pop(i_from)
532
+ seq_ids.insert(i_to if i_to <= len(seq_ids) else len(seq_ids), item)
533
+
534
+ items = self.window.core.presets.items
535
+ uuids = [items[pid].uuid for pid in seq_ids if pid in items and items[pid].uuid]
536
+ mode = self.window.core.config.get('mode')
537
+ self.window.controller.presets.persist_order_for_mode(mode, uuids)
538
+
539
+ return moved_id
540
+
541
+ # ----------------------------
542
+ # Drag visuals (safe, no delegate painting)
543
+ # ----------------------------
544
+
545
+ def _drag_pixmap_for_index(self, index) -> QPixmap | None:
546
+ """
547
+ Build a safe pixmap for dragged row without using delegate.paint (prevents crash).
548
+ """
549
+ try:
550
+ text = str(index.data(Qt.DisplayRole) or "")
551
+ fm = self.fontMetrics()
552
+ w = max(fm.horizontalAdvance(text) + 24, 80)
553
+ h = max(fm.height() + 10, 24)
554
+ pm = QPixmap(w, h)
555
+ pm.fill(Qt.transparent)
556
+
557
+ painter = QPainter()
558
+ painter.begin(pm)
559
+ try:
560
+ # background bubble
561
+ bg = self.palette().base().color()
562
+ bg.setAlpha(220)
563
+ painter.fillRect(pm.rect(), bg)
564
+ # border
565
+ pen = QPen(QColor(0, 0, 0, 40))
566
+ painter.setPen(pen)
567
+ painter.drawRect(pm.rect().adjusted(0, 0, -1, -1))
568
+ # text
569
+ painter.setPen(self.palette().text().color())
570
+ painter.drawText(pm.rect().adjusted(8, 0, -8, 0), Qt.AlignVCenter | Qt.AlignLeft, text)
571
+ finally:
572
+ painter.end()
573
+ return pm
574
+ except Exception:
575
+ return None
576
+
577
+ def startDrag(self, supportedActions):
578
+ """
579
+ Start drag with pixmap built from the actually dragged row (self._press_index).
580
+ Avoids using selection for drag visuals (no 'ghost' of another item).
581
+ """
582
+ if not self._dnd_enabled or self._press_index is None or not self._press_index.isValid():
583
+ return super().startDrag(supportedActions)
584
+
585
+ model = self.model()
586
+ drag = QDrag(self)
587
+ # mime data from the pressed index (not from selection)
588
+ try:
589
+ mime = model.mimeData([self._press_index])
590
+ except Exception:
591
+ mime = QMimeData()
592
+ drag.setMimeData(mime)
593
+
594
+ pm = self._drag_pixmap_for_index(self._press_index)
595
+ if pm is not None:
596
+ drag.setPixmap(pm)
597
+ drag.setHotSpot(pm.rect().center())
598
+
599
+ drag.exec(Qt.MoveAction)
600
+
601
+ # ----------------------------
602
+ # Refresh & painting
603
+ # ----------------------------
604
+
605
+ def _force_full_repaint(self):
606
+ """
607
+ Force a synchronous full repaint of the viewport and notify the view that data/layout could change.
608
+ This clears any stale drag visuals on some platforms/styles.
609
+ """
610
+ model = self.model()
611
+ if model is not None and model.rowCount() > 0:
612
+ top = model.index(0, 0)
613
+ bottom = model.index(model.rowCount() - 1, 0)
614
+ try:
615
+ model.dataChanged.emit(top, bottom, [Qt.DisplayRole])
616
+ except Exception:
617
+ pass
618
+ try:
619
+ model.layoutChanged.emit()
620
+ except Exception:
621
+ pass
622
+ self.viewport().repaint()
623
+
624
+ def _refresh_after_order_change(self, moved_id: str, follow_selection: bool):
625
+ """
626
+ Refresh the list from core order and keep selection/scroll stable.
627
+
628
+ For both DnD and context moves:
629
+ - if _selection_override_ids is set, layout will restore those IDs;
630
+ - otherwise, take current selected IDs and use them as override to ensure
631
+ selection 'follows element, not position'.
632
+ """
633
+ if not self._selection_override_ids:
634
+ self._selection_override_ids = self._current_selected_ids()
635
+
636
+ self.store_scroll_position()
637
+
638
+ # Use custom indicator only; do not re-enable native one here
639
+ self.setUpdatesEnabled(False)
640
+ try:
641
+ self.window.controller.presets.update_list()
642
+ self.restore_scroll_position()
643
+ finally:
644
+ self.setUpdatesEnabled(True)
645
+
646
+ # Clear helpers for context menu (layout will consume _selection_override_ids)
647
+ self._ctx_menu_original_ids = None
648
+ self._backup_selection = None
649
+
650
+ QApplication.processEvents(QEventLoop.ExcludeUserInputEvents | QEventLoop.ExcludeSocketNotifiers)
651
+ self._force_full_repaint()
652
+ QTimer.singleShot(0, self.viewport().update)
653
+
654
+ def _apply_after_drop(self):
655
+ """Execute deferred refresh after the drop event has fully finished in Qt."""
656
+ payload = self._pending_after_drop
657
+ self._pending_after_drop = None
658
+ if not payload:
659
+ return
660
+ moved_id, follow_selection = payload
661
+ self._refresh_after_order_change(moved_id, follow_selection)
662
+ # Activate moved preset in controller at the very end (deferred to avoid re-entrancy)
663
+ QTimer.singleShot(0, lambda mid=moved_id: self._finalize_select_after_drop(mid))
664
+
665
+ def _finalize_select_after_drop(self, moved_role_id: str):
666
+ """
667
+ Final activation of the moved preset in controller after DnD completed and view got refreshed.
668
+ This is intentionally deferred to the next event loop tick.
669
+ """
670
+ try:
671
+ pid = moved_role_id
672
+ if not pid:
673
+ ids = self._current_selected_ids()
674
+ pid = ids[0] if ids else ""
675
+ if pid:
676
+ self.window.controller.presets.select_by_id(pid)
677
+ except Exception:
678
+ pass
679
+
680
+ def _move_row(self, from_row: int, to_row: int):
681
+ """Move row programmatically; persist order and keep selection attached to the same item."""
682
+ if from_row == to_row:
683
+ return
684
+ moved_id = self._reorder_and_persist(from_row, to_row)
685
+ self._refresh_after_order_change(moved_id, follow_selection=False)
686
+
687
+ # --- Custom drop indicator helpers ---
688
+
689
+ def _compute_drop_locations(self, pos: QPoint) -> tuple[int, int]:
690
+ """
691
+ Compute both:
692
+ - to_row_drop: final insertion row used for reordering (after 'moving-down' adjustment),
693
+ - seam_row: row under which the visual indicator line should be drawn
694
+ in the current (pre-drop) view geometry.
695
+
696
+ This keeps visuals and the final insertion point perfectly aligned.
697
+
698
+ Returns: (to_row_drop, seam_row)
699
+ """
700
+ model = self.model()
701
+ if model is None:
702
+ return -1, -1
703
+
704
+ idx = self.indexAt(pos)
705
+
706
+ beyond_last = False
707
+ if not idx.isValid():
708
+ to_row_raw = model.rowCount() # append at the end
709
+ if model.rowCount() > 0:
710
+ last_idx = model.index(model.rowCount() - 1, 0)
711
+ last_rect = self.visualRect(last_idx)
712
+ if last_rect.isValid() and pos.y() > last_rect.bottom():
713
+ beyond_last = True
714
+ else:
715
+ rect = self.visualRect(idx)
716
+ to_row_raw = idx.row() + (1 if pos.y() > rect.center().y() else 0)
717
+
718
+ # Keep first row pinned (cannot insert above row 1)
719
+ if to_row_raw <= 1:
720
+ to_row_raw = 1
721
+
722
+ # seam row is always the boundary under the row at (to_row_raw - 1),
723
+ # except in explicit "beyond last" zone where we draw under the last row.
724
+ if model.rowCount() > 0:
725
+ if beyond_last:
726
+ seam_row = model.rowCount() - 1
727
+ else:
728
+ seam_row = max(0, min(model.rowCount() - 1, to_row_raw - 1))
729
+ else:
730
+ seam_row = -1
731
+
732
+ # Apply 'moving down' adjustment only to the logical insertion row,
733
+ # never to the visual seam (otherwise the line jumps one row up).
734
+ from_row = self._press_index.row() if (self._press_index and self._press_index.isValid()) else -1
735
+ to_row_drop = to_row_raw
736
+ if from_row >= 0 and to_row_raw > from_row and not beyond_last:
737
+ to_row_drop -= 1
738
+
739
+ # Clamp to valid ranges
740
+ to_row_drop = max(1, min(model.rowCount(), to_row_drop))
741
+ if seam_row >= 0:
742
+ seam_row = max(0, min(model.rowCount() - 1, seam_row))
743
+
744
+ return to_row_drop, seam_row
745
+
746
+ def _update_drop_indicator_from_pos(self, pos: QPoint):
747
+ """
748
+ Update custom drop indicator state based on cursor position.
749
+ Draws a single horizontal line under the row where the item will land.
750
+ """
751
+ if not self._dnd_enabled or self._model_updating:
752
+ self._clear_drop_indicator()
753
+ return
754
+
755
+ model = self.model()
756
+ if model is None or model.rowCount() <= 0:
757
+ self._clear_drop_indicator()
758
+ return
759
+
760
+ _, seam_row = self._compute_drop_locations(pos)
761
+ if seam_row < 0:
762
+ self._clear_drop_indicator()
763
+ return
764
+
765
+ if not self._drop_indicator_active or self._drop_indicator_to_row != seam_row:
766
+ self._drop_indicator_active = True
767
+ self._drop_indicator_to_row = seam_row
768
+ self.viewport().update()
769
+
770
+ def _clear_drop_indicator(self):
771
+ """Hide custom drop indicator."""
772
+ if self._drop_indicator_active or self._drop_indicator_to_row != -1:
773
+ self._drop_indicator_active = False
774
+ self._drop_indicator_to_row = -1
775
+ if self.viewport():
776
+ self.viewport().update()
777
+
778
+ def paintEvent(self, event):
779
+ """
780
+ Standard paint + overlay a clear drop indicator line at the computed insertion position.
781
+ """
782
+ super().paintEvent(event)
783
+
784
+ if not self._drop_indicator_active or not self._dnd_enabled:
785
+ return
786
+
787
+ model = self.model()
788
+ if model is None or model.rowCount() <= 0:
789
+ return
790
+
791
+ seam_row = self._drop_indicator_to_row
792
+ if seam_row < 0 or seam_row >= model.rowCount():
793
+ return
794
+
795
+ idx = model.index(seam_row, 0)
796
+ rect = self.visualRect(idx)
797
+ if not rect.isValid() or rect.height() <= 0:
798
+ return
799
+
800
+ # Line under the seam row
801
+ y = rect.bottom()
802
+ x1 = self._drop_indicator_padding
803
+ x2 = self.viewport().width() - self._drop_indicator_padding
804
+
805
+ painter = QPainter(self.viewport())
806
+ try:
807
+ # Use highlight color with good contrast; 1px thickness
808
+ color = self.palette().highlight().color()
809
+ color.setAlpha(220)
810
+ pen = QPen(color, 1)
811
+ pen.setCapStyle(Qt.RoundCap)
812
+ painter.setPen(pen)
813
+ painter.drawLine(x1, y, x2, y)
814
+ finally:
815
+ painter.end()
816
+
817
+ # ----------------------------
818
+ # Mouse / DnD events
819
+ # ----------------------------
820
+
821
+ def _mouse_event_point(self, event):
822
+ if hasattr(event, "position"):
823
+ try:
824
+ p = event.position()
825
+ if hasattr(p, "toPoint"):
826
+ return p.toPoint()
827
+ except Exception:
828
+ pass
829
+ if hasattr(event, "pos"):
830
+ return event.pos()
831
+ return self.viewport().mapFromGlobal(QCursor.pos())
832
+
197
833
  def mousePressEvent(self, event):
834
+ if self._model_updating:
835
+ event.ignore()
836
+ return
198
837
  if event.button() == Qt.LeftButton:
199
- index = self.indexAt(event.pos())
838
+ index = self.indexAt(self._mouse_event_point(event))
200
839
  if not index.isValid():
201
840
  return
202
- super().mousePressEvent(event)
841
+ # Freeze scroll for a moment to prevent jumps caused by selection-triggered refresh
842
+ self._freeze_scroll(250)
843
+ if self._dnd_enabled:
844
+ sel_model = self.selectionModel()
845
+ self._press_backup_selection = list(sel_model.selectedIndexes())
846
+ self._press_backup_current = self.currentIndex()
847
+ self._dragged_was_selected = any(ix.row() == index.row() for ix in self._press_backup_selection or [])
848
+ super().mousePressEvent(event)
849
+ # Keep old selection (do not auto-select dragged item yet)
850
+ sel_model.clearSelection()
851
+ for i in self._press_backup_selection or []:
852
+ sel_model.select(i, QItemSelectionModel.Select | QItemSelectionModel.Rows)
853
+ if self._press_backup_current and self._press_backup_current.isValid():
854
+ self.setCurrentIndex(self._press_backup_current)
855
+ self._press_pos = self._mouse_event_point(event)
856
+ self._press_index = index
857
+ self._drag_selection_applied = False
858
+ event.accept()
859
+ return
860
+ else:
861
+ super().mousePressEvent(event)
203
862
  elif event.button() == Qt.RightButton:
204
- index = self.indexAt(event.pos())
863
+ index = self.indexAt(self._mouse_event_point(event))
205
864
  if index.isValid():
206
865
  sel_model = self.selectionModel()
866
+ # Save original IDs (before we temporarily select right-click row)
867
+ self._ctx_menu_original_ids = []
868
+ for ix in sel_model.selectedRows():
869
+ pid = ix.data(self.ROLE_ID)
870
+ if pid:
871
+ self._ctx_menu_original_ids.append(pid)
872
+
207
873
  self._backup_selection = list(sel_model.selectedIndexes())
208
874
  sel_model.clearSelection()
209
875
  sel_model.select(
@@ -213,10 +879,192 @@ class PresetList(BaseList):
213
879
  else:
214
880
  super().mousePressEvent(event)
215
881
 
216
- def selectionCommand(self, index, event=None):
882
+ def mouseMoveEvent(self, event):
883
+ if self._model_updating:
884
+ return
885
+ if not self._dnd_enabled:
886
+ return super().mouseMoveEvent(event)
887
+ if self._press_index is None or self._press_pos is None:
888
+ return super().mouseMoveEvent(event)
889
+ if not (event.buttons() & Qt.LeftButton):
890
+ return super().mouseMoveEvent(event)
891
+
892
+ cur = self._mouse_event_point(event)
893
+ dist = (cur - self._press_pos).manhattanLength()
894
+ threshold = QApplication.startDragDistance()
895
+ if dist < threshold:
896
+ return
897
+
898
+ # Pin current.* at the top; prevent dragging it
899
+ if self._press_index.row() == 0 or bool(self._press_index.data(self.ROLE_IS_SPECIAL)):
900
+ return super().mouseMoveEvent(event)
901
+
902
+ # Exception rule: at the start of drag, select the dragged item (view-only to avoid re-entrancy)
903
+ if not self._drag_selection_applied:
904
+ try:
905
+ sel_model = self.selectionModel()
906
+ prev_unlocked = self.unlocked
907
+ self.unlocked = True
908
+ try:
909
+ sel_model.clearSelection()
910
+ sel_model.select(self._press_index, QItemSelectionModel.ClearAndSelect | QItemSelectionModel.Rows)
911
+ self.setCurrentIndex(self._press_index)
912
+ finally:
913
+ self.unlocked = prev_unlocked
914
+ except Exception:
915
+ pass
916
+ self._drag_selection_applied = True
917
+
918
+ self._dragging = True
919
+ self.setCursor(QCursor(Qt.ClosedHandCursor))
920
+ # Let base class proceed; it will trigger startDrag when needed.
921
+ super().mouseMoveEvent(event)
922
+
923
+ def mouseReleaseEvent(self, event):
924
+ if self._model_updating:
925
+ event.ignore()
926
+ return
927
+ try:
928
+ if self._dnd_enabled and event.button() == Qt.LeftButton:
929
+ self.unsetCursor()
930
+ self._clear_drop_indicator()
931
+ if not self._dragging:
932
+ idx = self.indexAt(self._mouse_event_point(event))
933
+ if idx.isValid():
934
+ pid = idx.data(self.ROLE_ID)
935
+ if pid:
936
+ # Keep scroll stable also for this late selection path
937
+ self._freeze_scroll(300)
938
+ self._pending_refocus_role_id = pid
939
+ self.window.controller.presets.select_by_id(pid)
940
+ QTimer.singleShot(0, self._apply_pending_refocus)
941
+ QTimer.singleShot(50, self._apply_pending_refocus)
942
+ else:
943
+ self.setCurrentIndex(idx)
944
+ self.window.controller.presets.select(idx.row())
945
+ finally:
946
+ self._press_pos = None
947
+ self._press_index = None
948
+ self._press_backup_selection = None
949
+ self._press_backup_current = None
950
+ self._dragging = False
951
+ self._dragged_was_selected = False
952
+ self._drag_selection_applied = False
953
+ super().mouseReleaseEvent(event)
954
+
955
+ def dragEnterEvent(self, event):
956
+ if self._model_updating:
957
+ event.ignore()
958
+ return
959
+ if not self._dnd_enabled:
960
+ return
961
+ event.setDropAction(Qt.MoveAction)
962
+ event.acceptProposedAction()
963
+ super().dragEnterEvent(event)
964
+ # Show indicator immediately on enter
965
+ self._update_drop_indicator_from_pos(self._mouse_event_point(event))
966
+
967
+ def dragLeaveEvent(self, event):
968
+ if self._model_updating:
969
+ event.ignore()
970
+ return
971
+ self.unsetCursor()
972
+ self._clear_drop_indicator()
973
+ super().dragLeaveEvent(event)
974
+
975
+ def dragMoveEvent(self, event):
976
+ if self._model_updating:
977
+ event.ignore()
978
+ return
979
+ if not self._dnd_enabled:
980
+ return
981
+
982
+ pos = self._mouse_event_point(event)
983
+ idx = self.indexAt(pos)
984
+ # Do not allow dropping into the pinned first row zone
985
+ if idx.isValid() and idx.row() == 0:
986
+ rect = self.visualRect(idx)
987
+ if pos.y() <= rect.center().y():
988
+ self._clear_drop_indicator()
989
+ event.ignore()
990
+ return
991
+
992
+ # Let base class process autoscroll and internal geometry first
993
+ event.setDropAction(Qt.MoveAction)
994
+ event.acceptProposedAction()
995
+ super().dragMoveEvent(event)
996
+
997
+ # Update custom indicator based on current cursor and updated viewport
998
+ self._update_drop_indicator_from_pos(pos)
999
+
1000
+ def dropEvent(self, event):
217
1001
  """
218
- Selection command
219
- :param index: Index
220
- :param event: Event
1002
+ Fully handle flat row-to-row move. Persist order and defer view rebuild to next event loop,
1003
+ so Qt can finish DnD teardown (prevents temporary disappearance).
221
1004
  """
1005
+ if self._model_updating:
1006
+ event.ignore()
1007
+ return
1008
+ if not self._dnd_enabled:
1009
+ return super().dropEvent(event)
1010
+
1011
+ model = self.model()
1012
+ if model is None:
1013
+ event.ignore()
1014
+ return
1015
+
1016
+ # Source row (from press index if available)
1017
+ if self._press_index is not None and self._press_index.isValid():
1018
+ from_row = self._press_index.row()
1019
+ else:
1020
+ cur = self.currentIndex()
1021
+ from_row = cur.row() if cur.isValid() else -1
1022
+
1023
+ if from_row < 0:
1024
+ event.ignore()
1025
+ self.unsetCursor()
1026
+ self._drag_selection_applied = False
1027
+ self._clear_drop_indicator()
1028
+ return
1029
+
1030
+ # Target row computed exactly the same way as the indicator (but with 'moving down' adjustment)
1031
+ to_row, _ = self._compute_drop_locations(self._mouse_event_point(event))
1032
+
1033
+ moved_id = self._reorder_and_persist(from_row, to_row)
1034
+
1035
+ # Defer the heavy refresh to the next event loop tick
1036
+ self._pending_after_drop = (moved_id, False)
1037
+ QTimer.singleShot(0, self._apply_after_drop)
1038
+
1039
+ # Properly finalize DnD in Qt and exit without mutating the model here
1040
+ event.setDropAction(Qt.MoveAction)
1041
+ event.acceptProposedAction()
1042
+ self.unsetCursor()
1043
+ self._drag_selection_applied = False
1044
+ self._clear_drop_indicator()
1045
+
1046
+ # ----------------------------
1047
+ # Legacy helper (not used in new path)
1048
+ # ----------------------------
1049
+
1050
+ def _persist_current_model_order(self):
1051
+ """Deprecated in favor of _reorder_and_persist; retained for backward compatibility if needed."""
1052
+ model = self.model()
1053
+ if model is None:
1054
+ return
1055
+ uuids = []
1056
+ for i in range(model.rowCount()):
1057
+ if i == 0:
1058
+ continue
1059
+ idx = model.index(i, 0)
1060
+ u = idx.data(self.ROLE_UUID)
1061
+ if u and isinstance(u, str):
1062
+ uuids.append(u)
1063
+ mode = self.window.core.config.get('mode')
1064
+ self.window.controller.presets.persist_order_for_mode(mode, uuids)
1065
+
1066
+ def selectionCommand(self, index, event=None):
1067
+ # Prevent selection changes while model is updating (guards against stale indexes)
1068
+ if self._model_updating:
1069
+ return QItemSelectionModel.NoUpdate
222
1070
  return super().selectionCommand(index, event)