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.
- pygpt_net/CHANGELOG.txt +14 -0
- pygpt_net/__init__.py +3 -3
- pygpt_net/controller/chat/common.py +115 -6
- pygpt_net/controller/chat/input.py +4 -1
- pygpt_net/controller/chat/response.py +8 -2
- pygpt_net/controller/presets/presets.py +121 -6
- pygpt_net/controller/settings/editor.py +0 -15
- pygpt_net/controller/settings/profile.py +16 -4
- pygpt_net/controller/settings/workdir.py +30 -5
- pygpt_net/controller/theme/common.py +4 -2
- pygpt_net/controller/theme/markdown.py +4 -7
- pygpt_net/controller/theme/theme.py +2 -1
- pygpt_net/controller/ui/ui.py +32 -7
- pygpt_net/core/agents/custom/__init__.py +7 -1
- pygpt_net/core/agents/custom/llama_index/factory.py +17 -6
- pygpt_net/core/agents/custom/llama_index/runner.py +52 -4
- pygpt_net/core/agents/custom/llama_index/utils.py +12 -1
- pygpt_net/core/agents/custom/router.py +45 -6
- pygpt_net/core/agents/custom/runner.py +11 -5
- pygpt_net/core/agents/custom/schema.py +3 -1
- pygpt_net/core/agents/custom/utils.py +13 -1
- pygpt_net/core/agents/runners/llama_workflow.py +65 -5
- pygpt_net/core/agents/runners/openai_workflow.py +2 -1
- pygpt_net/core/db/viewer.py +11 -5
- pygpt_net/core/node_editor/graph.py +18 -9
- pygpt_net/core/node_editor/models.py +9 -2
- pygpt_net/core/node_editor/types.py +15 -1
- pygpt_net/core/presets/presets.py +216 -29
- pygpt_net/core/render/markdown/parser.py +0 -2
- pygpt_net/core/render/web/renderer.py +76 -11
- pygpt_net/data/config/config.json +5 -6
- pygpt_net/data/config/models.json +3 -3
- pygpt_net/data/config/settings.json +2 -38
- pygpt_net/data/css/style.dark.css +18 -0
- pygpt_net/data/css/style.light.css +20 -1
- pygpt_net/data/locale/locale.de.ini +66 -1
- pygpt_net/data/locale/locale.en.ini +64 -3
- pygpt_net/data/locale/locale.es.ini +66 -1
- pygpt_net/data/locale/locale.fr.ini +66 -1
- pygpt_net/data/locale/locale.it.ini +66 -1
- pygpt_net/data/locale/locale.pl.ini +67 -2
- pygpt_net/data/locale/locale.uk.ini +66 -1
- pygpt_net/data/locale/locale.zh.ini +66 -1
- pygpt_net/data/locale/plugin.cmd_system.en.ini +62 -66
- pygpt_net/item/ctx.py +23 -1
- pygpt_net/provider/agents/llama_index/flow_from_schema.py +2 -2
- pygpt_net/provider/agents/llama_index/workflow/codeact.py +9 -6
- pygpt_net/provider/agents/llama_index/workflow/openai.py +38 -11
- pygpt_net/provider/agents/llama_index/workflow/planner.py +36 -16
- pygpt_net/provider/agents/llama_index/workflow/supervisor.py +60 -10
- pygpt_net/provider/agents/openai/agent.py +3 -1
- pygpt_net/provider/agents/openai/agent_b2b.py +13 -9
- pygpt_net/provider/agents/openai/agent_planner.py +6 -2
- pygpt_net/provider/agents/openai/agent_with_experts.py +4 -1
- pygpt_net/provider/agents/openai/agent_with_experts_feedback.py +4 -2
- pygpt_net/provider/agents/openai/agent_with_feedback.py +4 -2
- pygpt_net/provider/agents/openai/evolve.py +6 -2
- pygpt_net/provider/agents/openai/supervisor.py +3 -1
- pygpt_net/provider/api/openai/agents/response.py +1 -0
- pygpt_net/provider/core/config/patch.py +18 -1
- pygpt_net/provider/core/config/patches/patch_before_2_6_42.py +0 -6
- pygpt_net/tools/agent_builder/tool.py +48 -26
- pygpt_net/tools/agent_builder/ui/dialogs.py +36 -28
- pygpt_net/ui/__init__.py +2 -4
- pygpt_net/ui/dialog/about.py +58 -38
- pygpt_net/ui/dialog/db.py +142 -3
- pygpt_net/ui/dialog/preset.py +47 -8
- pygpt_net/ui/layout/toolbox/presets.py +64 -16
- pygpt_net/ui/main.py +2 -2
- pygpt_net/ui/widget/dialog/confirm.py +27 -3
- pygpt_net/ui/widget/dialog/db.py +0 -0
- pygpt_net/ui/widget/draw/painter.py +90 -1
- pygpt_net/ui/widget/lists/preset.py +908 -60
- pygpt_net/ui/widget/node_editor/command.py +10 -10
- pygpt_net/ui/widget/node_editor/config.py +157 -0
- pygpt_net/ui/widget/node_editor/editor.py +223 -153
- pygpt_net/ui/widget/node_editor/item.py +12 -11
- pygpt_net/ui/widget/node_editor/node.py +246 -13
- pygpt_net/ui/widget/node_editor/view.py +179 -63
- pygpt_net/ui/widget/tabs/output.py +1 -1
- pygpt_net/ui/widget/textarea/input.py +157 -23
- pygpt_net/utils.py +114 -2
- {pygpt_net-2.6.60.dist-info → pygpt_net-2.6.62.dist-info}/METADATA +26 -100
- {pygpt_net-2.6.60.dist-info → pygpt_net-2.6.62.dist-info}/RECORD +86 -85
- {pygpt_net-2.6.60.dist-info → pygpt_net-2.6.62.dist-info}/LICENSE +0 -0
- {pygpt_net-2.6.60.dist-info → pygpt_net-2.6.62.dist-info}/WHEEL +0 -0
- {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.
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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(
|
|
838
|
+
index = self.indexAt(self._mouse_event_point(event))
|
|
200
839
|
if not index.isValid():
|
|
201
840
|
return
|
|
202
|
-
|
|
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(
|
|
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
|
|
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
|
-
|
|
219
|
-
|
|
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)
|