pygpt-net 2.6.60__py3-none-any.whl → 2.6.61__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 (60) hide show
  1. pygpt_net/CHANGELOG.txt +7 -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/presets/presets.py +121 -6
  6. pygpt_net/controller/settings/editor.py +0 -15
  7. pygpt_net/controller/theme/markdown.py +2 -5
  8. pygpt_net/controller/ui/ui.py +4 -7
  9. pygpt_net/core/agents/custom/__init__.py +7 -1
  10. pygpt_net/core/agents/custom/llama_index/factory.py +17 -6
  11. pygpt_net/core/agents/custom/llama_index/runner.py +35 -2
  12. pygpt_net/core/agents/custom/llama_index/utils.py +12 -1
  13. pygpt_net/core/agents/custom/router.py +45 -6
  14. pygpt_net/core/agents/custom/runner.py +2 -1
  15. pygpt_net/core/agents/custom/schema.py +3 -1
  16. pygpt_net/core/agents/custom/utils.py +13 -1
  17. pygpt_net/core/db/viewer.py +11 -5
  18. pygpt_net/core/node_editor/graph.py +18 -9
  19. pygpt_net/core/node_editor/models.py +9 -2
  20. pygpt_net/core/node_editor/types.py +3 -1
  21. pygpt_net/core/presets/presets.py +216 -29
  22. pygpt_net/core/render/markdown/parser.py +0 -2
  23. pygpt_net/data/config/config.json +5 -6
  24. pygpt_net/data/config/models.json +3 -3
  25. pygpt_net/data/config/settings.json +2 -38
  26. pygpt_net/data/locale/locale.de.ini +64 -1
  27. pygpt_net/data/locale/locale.en.ini +62 -3
  28. pygpt_net/data/locale/locale.es.ini +64 -1
  29. pygpt_net/data/locale/locale.fr.ini +64 -1
  30. pygpt_net/data/locale/locale.it.ini +64 -1
  31. pygpt_net/data/locale/locale.pl.ini +65 -2
  32. pygpt_net/data/locale/locale.uk.ini +64 -1
  33. pygpt_net/data/locale/locale.zh.ini +64 -1
  34. pygpt_net/data/locale/plugin.cmd_system.en.ini +62 -66
  35. pygpt_net/provider/agents/llama_index/flow_from_schema.py +2 -2
  36. pygpt_net/provider/core/config/patch.py +10 -1
  37. pygpt_net/provider/core/config/patches/patch_before_2_6_42.py +0 -6
  38. pygpt_net/tools/agent_builder/tool.py +42 -26
  39. pygpt_net/tools/agent_builder/ui/dialogs.py +60 -11
  40. pygpt_net/ui/__init__.py +2 -4
  41. pygpt_net/ui/dialog/about.py +58 -38
  42. pygpt_net/ui/dialog/db.py +142 -3
  43. pygpt_net/ui/dialog/preset.py +47 -8
  44. pygpt_net/ui/layout/toolbox/presets.py +52 -16
  45. pygpt_net/ui/widget/dialog/db.py +0 -0
  46. pygpt_net/ui/widget/lists/preset.py +644 -60
  47. pygpt_net/ui/widget/node_editor/command.py +10 -10
  48. pygpt_net/ui/widget/node_editor/config.py +157 -0
  49. pygpt_net/ui/widget/node_editor/editor.py +183 -151
  50. pygpt_net/ui/widget/node_editor/item.py +12 -11
  51. pygpt_net/ui/widget/node_editor/node.py +267 -12
  52. pygpt_net/ui/widget/node_editor/view.py +180 -63
  53. pygpt_net/ui/widget/tabs/output.py +1 -1
  54. pygpt_net/ui/widget/textarea/input.py +2 -2
  55. pygpt_net/utils.py +114 -2
  56. {pygpt_net-2.6.60.dist-info → pygpt_net-2.6.61.dist-info}/METADATA +11 -94
  57. {pygpt_net-2.6.60.dist-info → pygpt_net-2.6.61.dist-info}/RECORD +59 -58
  58. {pygpt_net-2.6.60.dist-info → pygpt_net-2.6.61.dist-info}/LICENSE +0 -0
  59. {pygpt_net-2.6.60.dist-info → pygpt_net-2.6.61.dist-info}/WHEEL +0 -0
  60. {pygpt_net-2.6.60.dist-info → pygpt_net-2.6.61.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 03: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,175 @@ 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
+ self.setDropIndicatorShown(False)
69
+
70
+ self._press_pos = None
71
+ self._press_index = None
72
+ self._press_backup_selection = None
73
+ self._press_backup_current = None
74
+ self._dragging = False
75
+ self._dragged_was_selected = False
76
+
77
+ # Mark that we already applied selection at drag start (one-shot per DnD)
78
+ self._drag_selection_applied = False
79
+
80
+ # ID-based selection persistence (single selection list)
81
+ self._saved_selection_ids = None
82
+
83
+ # Defer refresh payload after drop (DnD teardown must finish first)
84
+ self._pending_after_drop = None
85
+
86
+ # Guard against input during model rebuild (prevents crashes on quick clicks)
87
+ self._model_updating = False
88
+
89
+ # Keep original selection IDs before opening context menu (right-click)
90
+ self._ctx_menu_original_ids = None
91
+
92
+ # One-shot forced selection after refresh (list of ROLE_ID)
93
+ self._selection_override_ids = None
94
+
95
+ # -------- Public helpers to protect updates --------
96
+
97
+ def begin_model_update(self):
98
+ """Temporarily block user interaction while the model/view is rebuilt."""
99
+ self._model_updating = True
100
+ self.setEnabled(False)
101
+
102
+ def end_model_update(self):
103
+ """Re-enable interaction after model/view rebuild is complete."""
104
+ self.setEnabled(True)
105
+ self._model_updating = False
106
+
107
+ # ---------------------------------------------------
108
+
109
+ def set_dnd_enabled(self, enabled: bool):
110
+ """
111
+ Toggle DnD behaviour at runtime.
112
+ Using DragDrop (not InternalMove) to avoid implicit Qt reordering.
113
+ """
114
+ self._dnd_enabled = bool(enabled)
115
+ if self._dnd_enabled:
116
+ self.setDragEnabled(True)
117
+ self.setAcceptDrops(True)
118
+ self.setDragDropMode(QAbstractItemView.DragDrop)
119
+ self.setDropIndicatorShown(True)
120
+ else:
121
+ self.setDragEnabled(False)
122
+ self.setAcceptDrops(False)
123
+ self.setDragDropMode(QAbstractItemView.NoDragDrop)
124
+ self.setDropIndicatorShown(False)
125
+ self.unsetCursor()
126
+
127
+ def backup_selection(self):
128
+ """
129
+ Persist selected preset identity (by ROLE_ID) instead of raw indexes.
130
+ """
131
+ try:
132
+ sel_rows = self.selectionModel().selectedRows()
133
+ ids = []
134
+ for ix in sel_rows:
135
+ pid = ix.data(self.ROLE_ID)
136
+ if pid:
137
+ ids.append(str(pid))
138
+ self._saved_selection_ids = ids if ids else None
139
+ except Exception:
140
+ self._saved_selection_ids = None
141
+
142
+ def restore_selection(self):
143
+ """
144
+ Restore selection by ROLE_ID to keep it attached to the same item regardless of position.
145
+ """
146
+ ids = self._saved_selection_ids or []
147
+ self._saved_selection_ids = None
148
+ if not ids:
149
+ return
150
+ model = self.model()
151
+ if model is None:
152
+ return
153
+ target_id = ids[0]
154
+ sel_model = self.selectionModel()
155
+ prev_unlocked = self.unlocked
156
+ self.unlocked = True
157
+ try:
158
+ sel_model.clearSelection()
159
+ first_idx = None
160
+ for r in range(model.rowCount()):
161
+ idx = model.index(r, 0)
162
+ pid = idx.data(self.ROLE_ID)
163
+ if pid == target_id:
164
+ first_idx = idx
165
+ break
166
+ if first_idx is not None and first_idx.isValid():
167
+ sel_model.select(first_idx, QItemSelectionModel.ClearAndSelect | QItemSelectionModel.Rows)
168
+ self.setCurrentIndex(first_idx)
169
+ self.scrollTo(first_idx)
170
+ finally:
171
+ self.unlocked = prev_unlocked
172
+
173
+ def _current_selected_ids(self) -> list[str]:
174
+ """Read current selection IDs (ROLE_ID)."""
175
+ try:
176
+ return [ix.data(self.ROLE_ID) for ix in self.selectionModel().selectedRows() if ix.data(self.ROLE_ID)]
177
+ except Exception:
178
+ return []
179
+
48
180
  def click(self, val):
49
- row = val.row()
181
+ """Row click handler; select by ID (stable under reordering)."""
182
+ if self._model_updating:
183
+ return
184
+ index = val
185
+ if not index.isValid():
186
+ return
187
+ preset_id = index.data(self.ROLE_ID)
188
+ if preset_id:
189
+ self.window.controller.presets.select_by_id(preset_id)
190
+ self.selection = self.selectionModel().selection()
191
+ return
192
+ row = index.row()
50
193
  if row >= 0:
51
194
  self.window.controller.presets.select(row)
52
195
  self.selection = self.selectionModel().selection()
53
196
 
54
197
  def dblclick(self, val):
55
- """
56
- Double click event
57
-
58
- :param val: double click event
59
- """
198
+ """Double click event"""
199
+ if self._model_updating:
200
+ return
60
201
  row = val.row()
61
202
  if row >= 0:
62
203
  self.window.controller.presets.editor.edit(row)
63
204
 
64
205
  def show_context_menu(self, pos: QPoint):
65
- """
66
- Context menu event
67
-
68
- :param pos: QPoint
69
- """
206
+ """Context menu event"""
207
+ if self._model_updating:
208
+ return
70
209
  global_pos = self.viewport().mapToGlobal(pos)
71
210
  mode = self.window.core.config.get('mode')
72
211
  index = self.indexAt(pos)
73
212
  idx = index.row()
74
213
 
75
214
  preset = None
215
+ preset_id = None
76
216
  if idx >= 0:
77
217
  preset_id = self.window.core.presets.get_by_idx(idx, mode)
78
218
  if preset_id:
79
219
  preset = self.window.core.presets.items.get(preset_id)
80
220
 
81
221
  is_current = idx >= 0 and self.window.controller.presets.is_current(idx)
222
+ is_special = bool(index.data(self.ROLE_IS_SPECIAL)) if index.isValid() else False
82
223
 
83
224
  if idx >= 0:
84
225
  menu = QMenu(self)
@@ -100,6 +241,16 @@ class PresetList(BaseList):
100
241
  duplicate_act = QAction(self._ICO_COPY, trans('preset.action.duplicate'), menu)
101
242
  duplicate_act.triggered.connect(lambda checked=False, it=index: self.action_duplicate(it))
102
243
 
244
+ if self._dnd_enabled and not is_current and not is_special:
245
+ up_act = QAction(self._ICO_UP, trans('common.up'), menu)
246
+ down_act = QAction(self._ICO_DOWN, trans('common.down'), menu)
247
+ up_act.setEnabled(idx > 1)
248
+ down_act.setEnabled(idx < (self.model().rowCount() - 1))
249
+ up_act.triggered.connect(lambda checked=False, it=index: self.action_move_up(it))
250
+ down_act.triggered.connect(lambda checked=False, it=index: self.action_move_down(it))
251
+ menu.addAction(up_act)
252
+ menu.addAction(down_act)
253
+
103
254
  if is_current:
104
255
  edit_act.setEnabled(False)
105
256
  restore_act = QAction(self._ICO_UNDO, trans('dialog.editor.btn.defaults'), menu)
@@ -115,10 +266,7 @@ class PresetList(BaseList):
115
266
  self.selection = self.selectionModel().selection()
116
267
  menu.exec_(global_pos)
117
268
 
118
- # store previous scroll position
119
269
  self.store_scroll_position()
120
-
121
- # restore selection if it was backed up
122
270
  if self.restore_after_ctx_menu:
123
271
  if self._backup_selection is not None:
124
272
  sel_model = self.selectionModel()
@@ -128,82 +276,340 @@ class PresetList(BaseList):
128
276
  i, QItemSelectionModel.Select | QItemSelectionModel.Rows
129
277
  )
130
278
  self._backup_selection = None
131
-
132
- # restore scroll position
133
279
  self.restore_after_ctx_menu = True
134
280
  self.restore_scroll_position()
135
281
 
136
282
  def action_edit(self, item):
137
- """
138
- Edit action handler
139
-
140
- :param item: list item
141
- """
142
283
  idx = item.row()
143
284
  if idx >= 0:
144
- self.restore_after_ctx_menu = False # do not restore selection after context menu
285
+ self.restore_after_ctx_menu = False
145
286
  self.window.controller.presets.editor.edit(idx)
146
287
 
147
288
  def action_duplicate(self, item):
148
- """
149
- Duplicate action handler
150
-
151
- :param item: list item
152
- """
153
289
  idx = item.row()
154
290
  if idx >= 0:
155
- self.restore_after_ctx_menu = False # do not restore selection after context menu
291
+ self.restore_after_ctx_menu = False
156
292
  self.window.controller.presets.duplicate(idx)
157
293
 
158
294
  def action_delete(self, item):
159
- """
160
- Delete action handler
161
-
162
- :param item: list item
163
- """
164
295
  idx = item.row()
165
296
  if idx >= 0:
166
- self.restore_after_ctx_menu = False # do not restore selection after context menu
297
+ self.restore_after_ctx_menu = False
167
298
  self.window.controller.presets.delete(idx)
168
299
 
169
300
  def action_restore(self, item):
170
- """
171
- Restore action handler
172
-
173
- :param item: list item
174
- """
175
301
  self.window.controller.presets.restore()
176
302
 
177
303
  def action_enable(self, item):
178
- """
179
- Enable action handler
180
-
181
- :param item: list item
182
- """
183
304
  idx = item.row()
184
305
  if idx >= 0:
185
306
  self.window.controller.presets.enable(idx)
186
307
 
187
308
  def action_disable(self, item):
188
- """
189
- Disable action handler
190
-
191
- :param item: list item
192
- """
193
309
  idx = item.row()
194
310
  if idx >= 0:
195
311
  self.window.controller.presets.disable(idx)
196
312
 
313
+ def action_move_up(self, item):
314
+ row = item.row()
315
+ if row <= 1:
316
+ return
317
+ self.restore_after_ctx_menu = False
318
+ # Select the moved element (exception rule for context Up)
319
+ moved_role_id = item.data(self.ROLE_ID)
320
+ if moved_role_id:
321
+ self._selection_override_ids = [moved_role_id]
322
+ # Keep controller in sync with the view selection
323
+ self.window.controller.presets.select_by_id(moved_role_id)
324
+ self._move_row(row, row - 1)
325
+
326
+ def action_move_down(self, item):
327
+ row = item.row()
328
+ if row < 0 or row >= (self.model().rowCount() - 1):
329
+ return
330
+ if row == 0:
331
+ return
332
+ self.restore_after_ctx_menu = False
333
+ # Select the moved element (exception rule for context Down)
334
+ moved_role_id = item.data(self.ROLE_ID)
335
+ if moved_role_id:
336
+ self._selection_override_ids = [moved_role_id]
337
+ # Keep controller in sync with the view selection
338
+ self.window.controller.presets.select_by_id(moved_role_id)
339
+ self._move_row(row, row + 1)
340
+
341
+ # ----------------------------
342
+ # Ordering helpers (core-based)
343
+ # ----------------------------
344
+
345
+ def _core_regular_ids_for_mode(self) -> list[str]:
346
+ """Return current ordered preset IDs for mode, excluding pinned current.<mode>."""
347
+ mode = self.window.core.config.get('mode')
348
+ data = self.window.core.presets.get_by_mode(mode) or {}
349
+ ids = list(data.keys())
350
+ if ids and ids[0].startswith("current."):
351
+ ids = ids[1:]
352
+ return ids
353
+
354
+ def _core_regular_uuids_for_mode(self) -> list[str]:
355
+ """UUID list resolved from core ordered IDs (excluding pinned)."""
356
+ ids = self._core_regular_ids_for_mode()
357
+ items = self.window.core.presets.items
358
+ out = []
359
+ for pid in ids:
360
+ it = items.get(pid)
361
+ if it and it.uuid:
362
+ out.append(it.uuid)
363
+ return out
364
+
365
+ def _collect_regular_uuids(self) -> list[str]:
366
+ """Backward-compatible wrapper used by older code: now returns core-based UUIDs."""
367
+ return self._core_regular_uuids_for_mode()
368
+
369
+ def _is_row_selected(self, row: int) -> bool:
370
+ """Check if given row is currently selected."""
371
+ try:
372
+ sel = self.selectionModel().selectedRows()
373
+ return any(ix.row() == row for ix in sel)
374
+ except Exception:
375
+ return False
376
+
377
+ def _reorder_and_persist(self, from_row: int, to_row: int) -> str:
378
+ """
379
+ Compute new UUID order using core order (not the view), then persist it.
380
+ Returns moved preset ID (filename) for later selection if needed.
381
+ """
382
+ if from_row <= 0 or to_row <= 0:
383
+ return ""
384
+
385
+ ids_seq = self._core_regular_ids_for_mode()
386
+ if not ids_seq:
387
+ return ""
388
+
389
+ i_from = from_row - 1
390
+ i_to = to_row - 1
391
+ if i_from < 0 or i_from >= len(ids_seq):
392
+ return ""
393
+ if i_to < 0:
394
+ i_to = 0
395
+ if i_to > len(ids_seq):
396
+ i_to = len(ids_seq)
397
+
398
+ moved_id = ids_seq[i_from]
399
+ seq_ids = list(ids_seq)
400
+ item = seq_ids.pop(i_from)
401
+ seq_ids.insert(i_to if i_to <= len(seq_ids) else len(seq_ids), item)
402
+
403
+ items = self.window.core.presets.items
404
+ uuids = [items[pid].uuid for pid in seq_ids if pid in items and items[pid].uuid]
405
+ mode = self.window.core.config.get('mode')
406
+ self.window.controller.presets.persist_order_for_mode(mode, uuids)
407
+
408
+ return moved_id
409
+
410
+ # ----------------------------
411
+ # Drag visuals (safe, no delegate painting)
412
+ # ----------------------------
413
+
414
+ def _drag_pixmap_for_index(self, index) -> QPixmap | None:
415
+ """
416
+ Build a safe pixmap for dragged row without using delegate.paint (prevents crash).
417
+ """
418
+ try:
419
+ text = str(index.data(Qt.DisplayRole) or "")
420
+ fm = self.fontMetrics()
421
+ w = max(fm.horizontalAdvance(text) + 24, 80)
422
+ h = max(fm.height() + 10, 24)
423
+ pm = QPixmap(w, h)
424
+ pm.fill(Qt.transparent)
425
+
426
+ painter = QPainter()
427
+ painter.begin(pm)
428
+ try:
429
+ # background bubble
430
+ bg = self.palette().base().color()
431
+ bg.setAlpha(220)
432
+ painter.fillRect(pm.rect(), bg)
433
+ # border
434
+ pen = QPen(QColor(0, 0, 0, 40))
435
+ painter.setPen(pen)
436
+ painter.drawRect(pm.rect().adjusted(0, 0, -1, -1))
437
+ # text
438
+ painter.setPen(self.palette().text().color())
439
+ painter.drawText(pm.rect().adjusted(8, 0, -8, 0), Qt.AlignVCenter | Qt.AlignLeft, text)
440
+ finally:
441
+ painter.end()
442
+ return pm
443
+ except Exception:
444
+ return None
445
+
446
+ def startDrag(self, supportedActions):
447
+ """
448
+ Start drag with pixmap built from the actually dragged row (self._press_index).
449
+ Avoids using selection for drag visuals (no 'ghost' of another item).
450
+ """
451
+ if not self._dnd_enabled or self._press_index is None or not self._press_index.isValid():
452
+ return super().startDrag(supportedActions)
453
+
454
+ model = self.model()
455
+ drag = QDrag(self)
456
+ # mime data from the pressed index (not from selection)
457
+ try:
458
+ mime = model.mimeData([self._press_index])
459
+ except Exception:
460
+ mime = QMimeData()
461
+ drag.setMimeData(mime)
462
+
463
+ pm = self._drag_pixmap_for_index(self._press_index)
464
+ if pm is not None:
465
+ drag.setPixmap(pm)
466
+ drag.setHotSpot(pm.rect().center())
467
+
468
+ drag.exec(Qt.MoveAction)
469
+
470
+ # ----------------------------
471
+ # Refresh & painting
472
+ # ----------------------------
473
+
474
+ def _force_full_repaint(self):
475
+ """
476
+ Force a synchronous full repaint of the viewport and notify the view that data/layout could change.
477
+ This clears any stale drag visuals on some platforms/styles.
478
+ """
479
+ model = self.model()
480
+ if model is not None and model.rowCount() > 0:
481
+ top = model.index(0, 0)
482
+ bottom = model.index(model.rowCount() - 1, 0)
483
+ try:
484
+ model.dataChanged.emit(top, bottom, [Qt.DisplayRole])
485
+ except Exception:
486
+ pass
487
+ try:
488
+ model.layoutChanged.emit()
489
+ except Exception:
490
+ pass
491
+ self.viewport().repaint()
492
+
493
+ def _refresh_after_order_change(self, moved_id: str, follow_selection: bool):
494
+ """
495
+ Refresh the list from core order and keep selection/scroll stable.
496
+
497
+ For both DnD and context moves:
498
+ - if _selection_override_ids is set, layout will restore those IDs;
499
+ - otherwise, take current selected IDs and use them as override to ensure
500
+ selection 'follows element, not position'.
501
+ """
502
+ if not self._selection_override_ids:
503
+ self._selection_override_ids = self._current_selected_ids()
504
+
505
+ self.store_scroll_position()
506
+
507
+ di_prev = self._dnd_enabled
508
+ self.setDropIndicatorShown(False)
509
+ self.setUpdatesEnabled(False)
510
+ try:
511
+ self.window.controller.presets.update_list()
512
+ self.restore_scroll_position()
513
+ finally:
514
+ self.setUpdatesEnabled(True)
515
+ if di_prev and self._dnd_enabled:
516
+ self.setDropIndicatorShown(True)
517
+
518
+ # Clear helpers for context menu (layout will consume _selection_override_ids)
519
+ self._ctx_menu_original_ids = None
520
+ self._backup_selection = None
521
+
522
+ QApplication.processEvents(QEventLoop.ExcludeUserInputEvents | QEventLoop.ExcludeSocketNotifiers)
523
+ self._force_full_repaint()
524
+ QTimer.singleShot(0, self.viewport().update)
525
+
526
+ def _apply_after_drop(self):
527
+ """Execute deferred refresh after the drop event has fully finished in Qt."""
528
+ payload = self._pending_after_drop
529
+ self._pending_after_drop = None
530
+ if not payload:
531
+ return
532
+ moved_id, follow_selection = payload
533
+ self._refresh_after_order_change(moved_id, follow_selection)
534
+ # Activate moved preset in controller at the very end (deferred to avoid re-entrancy)
535
+ QTimer.singleShot(0, lambda mid=moved_id: self._finalize_select_after_drop(mid))
536
+
537
+ def _finalize_select_after_drop(self, moved_role_id: str):
538
+ """
539
+ Final activation of the moved preset in controller after DnD completed and view got refreshed.
540
+ This is intentionally deferred to the next event loop tick.
541
+ """
542
+ try:
543
+ pid = moved_role_id
544
+ if not pid:
545
+ ids = self._current_selected_ids()
546
+ pid = ids[0] if ids else ""
547
+ if pid:
548
+ self.window.controller.presets.select_by_id(pid)
549
+ except Exception:
550
+ pass
551
+
552
+ def _move_row(self, from_row: int, to_row: int):
553
+ """Move row programmatically; persist order and keep selection attached to the same item."""
554
+ if from_row == to_row:
555
+ return
556
+ moved_id = self._reorder_and_persist(from_row, to_row)
557
+ self._refresh_after_order_change(moved_id, follow_selection=False)
558
+
559
+ # ----------------------------
560
+ # Mouse / DnD events
561
+ # ----------------------------
562
+
563
+ def _mouse_event_point(self, event):
564
+ if hasattr(event, "position"):
565
+ try:
566
+ p = event.position()
567
+ if hasattr(p, "toPoint"):
568
+ return p.toPoint()
569
+ except Exception:
570
+ pass
571
+ if hasattr(event, "pos"):
572
+ return event.pos()
573
+ return self.viewport().mapFromGlobal(QCursor.pos())
574
+
197
575
  def mousePressEvent(self, event):
576
+ if self._model_updating:
577
+ event.ignore()
578
+ return
198
579
  if event.button() == Qt.LeftButton:
199
- index = self.indexAt(event.pos())
580
+ index = self.indexAt(self._mouse_event_point(event))
200
581
  if not index.isValid():
201
582
  return
202
- super().mousePressEvent(event)
583
+ if self._dnd_enabled:
584
+ sel_model = self.selectionModel()
585
+ self._press_backup_selection = list(sel_model.selectedIndexes())
586
+ self._press_backup_current = self.currentIndex()
587
+ self._dragged_was_selected = any(ix.row() == index.row() for ix in self._press_backup_selection or [])
588
+ super().mousePressEvent(event)
589
+ # Keep old selection (do not auto-select dragged item yet)
590
+ sel_model.clearSelection()
591
+ for i in self._press_backup_selection or []:
592
+ sel_model.select(i, QItemSelectionModel.Select | QItemSelectionModel.Rows)
593
+ if self._press_backup_current and self._press_backup_current.isValid():
594
+ self.setCurrentIndex(self._press_backup_current)
595
+ self._press_pos = self._mouse_event_point(event)
596
+ self._press_index = index
597
+ self._drag_selection_applied = False
598
+ event.accept()
599
+ return
600
+ else:
601
+ super().mousePressEvent(event)
203
602
  elif event.button() == Qt.RightButton:
204
- index = self.indexAt(event.pos())
603
+ index = self.indexAt(self._mouse_event_point(event))
205
604
  if index.isValid():
206
605
  sel_model = self.selectionModel()
606
+ # Save original IDs (before we temporarily select right-click row)
607
+ self._ctx_menu_original_ids = []
608
+ for ix in sel_model.selectedRows():
609
+ pid = ix.data(self.ROLE_ID)
610
+ if pid:
611
+ self._ctx_menu_original_ids.append(pid)
612
+
207
613
  self._backup_selection = list(sel_model.selectedIndexes())
208
614
  sel_model.clearSelection()
209
615
  sel_model.select(
@@ -213,10 +619,188 @@ class PresetList(BaseList):
213
619
  else:
214
620
  super().mousePressEvent(event)
215
621
 
216
- def selectionCommand(self, index, event=None):
622
+ def mouseMoveEvent(self, event):
623
+ if self._model_updating:
624
+ return
625
+ if not self._dnd_enabled:
626
+ return super().mouseMoveEvent(event)
627
+ if self._press_index is None or self._press_pos is None:
628
+ return super().mouseMoveEvent(event)
629
+ if not (event.buttons() & Qt.LeftButton):
630
+ return super().mouseMoveEvent(event)
631
+
632
+ cur = self._mouse_event_point(event)
633
+ dist = (cur - self._press_pos).manhattanLength()
634
+ threshold = QApplication.startDragDistance()
635
+ if dist < threshold:
636
+ return
637
+
638
+ # Pin current.* at the top; prevent dragging it
639
+ if self._press_index.row() == 0 or bool(self._press_index.data(self.ROLE_IS_SPECIAL)):
640
+ return super().mouseMoveEvent(event)
641
+
642
+ # Exception rule: at the start of drag, select the dragged item (view-only to avoid re-entrancy)
643
+ if not self._drag_selection_applied:
644
+ try:
645
+ sel_model = self.selectionModel()
646
+ prev_unlocked = self.unlocked
647
+ self.unlocked = True
648
+ try:
649
+ sel_model.clearSelection()
650
+ sel_model.select(self._press_index, QItemSelectionModel.ClearAndSelect | QItemSelectionModel.Rows)
651
+ self.setCurrentIndex(self._press_index)
652
+ finally:
653
+ self.unlocked = prev_unlocked
654
+ except Exception:
655
+ pass
656
+ self._drag_selection_applied = True
657
+
658
+ self._dragging = True
659
+ self.setCursor(QCursor(Qt.ClosedHandCursor))
660
+ super().mouseMoveEvent(event)
661
+
662
+ def mouseReleaseEvent(self, event):
663
+ if self._model_updating:
664
+ event.ignore()
665
+ return
666
+ try:
667
+ if self._dnd_enabled and event.button() == Qt.LeftButton:
668
+ self.unsetCursor()
669
+ if not self._dragging:
670
+ idx = self.indexAt(self._mouse_event_point(event))
671
+ if idx.isValid():
672
+ pid = idx.data(self.ROLE_ID)
673
+ if pid:
674
+ self.window.controller.presets.select_by_id(pid)
675
+ else:
676
+ self.setCurrentIndex(idx)
677
+ self.window.controller.presets.select(idx.row())
678
+ finally:
679
+ self._press_pos = None
680
+ self._press_index = None
681
+ self._press_backup_selection = None
682
+ self._press_backup_current = None
683
+ self._dragging = False
684
+ self._dragged_was_selected = False
685
+ self._drag_selection_applied = False
686
+ super().mouseReleaseEvent(event)
687
+
688
+ def dragEnterEvent(self, event):
689
+ if self._model_updating:
690
+ event.ignore()
691
+ return
692
+ if not self._dnd_enabled:
693
+ return
694
+ event.setDropAction(Qt.MoveAction)
695
+ event.acceptProposedAction()
696
+
697
+ def dragLeaveEvent(self, event):
698
+ if self._model_updating:
699
+ event.ignore()
700
+ return
701
+ self.unsetCursor()
702
+ super().dragLeaveEvent(event)
703
+
704
+ def dragMoveEvent(self, event):
705
+ if self._model_updating:
706
+ event.ignore()
707
+ return
708
+ if not self._dnd_enabled:
709
+ return
710
+ event.setDropAction(Qt.MoveAction)
711
+
712
+ pos = self._mouse_event_point(event)
713
+ idx = self.indexAt(pos)
714
+ # Do not allow dropping into the pinned first row zone
715
+ if idx.isValid() and idx.row() == 0:
716
+ rect = self.visualRect(idx)
717
+ if pos.y() <= rect.center().y():
718
+ event.ignore()
719
+ return
720
+ event.acceptProposedAction()
721
+
722
+ def dropEvent(self, event):
217
723
  """
218
- Selection command
219
- :param index: Index
220
- :param event: Event
724
+ Fully handle flat row-to-row move. Persist order and defer view rebuild to next event loop,
725
+ so Qt can finish DnD teardown (prevents temporary disappearance).
221
726
  """
727
+ if self._model_updating:
728
+ event.ignore()
729
+ return
730
+ if not self._dnd_enabled:
731
+ return super().dropEvent(event)
732
+
733
+ model = self.model()
734
+ if model is None:
735
+ event.ignore()
736
+ return
737
+
738
+ # Source row (from press index if available)
739
+ if self._press_index is not None and self._press_index.isValid():
740
+ from_row = self._press_index.row()
741
+ else:
742
+ cur = self.currentIndex()
743
+ from_row = cur.row() if cur.isValid() else -1
744
+
745
+ if from_row < 0:
746
+ event.ignore()
747
+ self.unsetCursor()
748
+ self._drag_selection_applied = False
749
+ return
750
+
751
+ # Target row
752
+ pos = self._mouse_event_point(event)
753
+ idx = self.indexAt(pos)
754
+ if not idx.isValid():
755
+ to_row = model.rowCount() # append
756
+ else:
757
+ rect = self.visualRect(idx)
758
+ to_row = idx.row()
759
+ if pos.y() > rect.center().y():
760
+ to_row += 1
761
+
762
+ # Keep first row pinned
763
+ if to_row <= 1:
764
+ to_row = 1
765
+
766
+ # Adjust when moving down (Qt inserts before position)
767
+ if to_row > from_row:
768
+ to_row -= 1
769
+
770
+ moved_id = self._reorder_and_persist(from_row, to_row)
771
+
772
+ # Defer the heavy refresh to the next event loop tick
773
+ self._pending_after_drop = (moved_id, False)
774
+ QTimer.singleShot(0, self._apply_after_drop)
775
+
776
+ # Properly finalize DnD in Qt and exit without mutating the model here
777
+ event.setDropAction(Qt.MoveAction)
778
+ event.acceptProposedAction()
779
+ self.unsetCursor()
780
+ self._drag_selection_applied = False
781
+
782
+ # ----------------------------
783
+ # Legacy helper (not used in new path)
784
+ # ----------------------------
785
+
786
+ def _persist_current_model_order(self):
787
+ """Deprecated in favor of _reorder_and_persist; retained for backward compatibility if needed."""
788
+ model = self.model()
789
+ if model is None:
790
+ return
791
+ uuids = []
792
+ for i in range(model.rowCount()):
793
+ if i == 0:
794
+ continue
795
+ idx = model.index(i, 0)
796
+ u = idx.data(self.ROLE_UUID)
797
+ if u and isinstance(u, str):
798
+ uuids.append(u)
799
+ mode = self.window.core.config.get('mode')
800
+ self.window.controller.presets.persist_order_for_mode(mode, uuids)
801
+
802
+ def selectionCommand(self, index, event=None):
803
+ # Prevent selection changes while model is updating (guards against stale indexes)
804
+ if self._model_updating:
805
+ return QItemSelectionModel.NoUpdate
222
806
  return super().selectionCommand(index, event)