pygpt-net 2.6.61__py3-none-any.whl → 2.6.63__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 +12 -0
- pygpt_net/__init__.py +3 -3
- pygpt_net/controller/chat/response.py +8 -2
- pygpt_net/controller/presets/editor.py +65 -1
- 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 +2 -2
- pygpt_net/controller/theme/theme.py +2 -1
- pygpt_net/controller/ui/ui.py +31 -3
- pygpt_net/core/agents/custom/llama_index/runner.py +30 -52
- pygpt_net/core/agents/custom/runner.py +199 -76
- pygpt_net/core/agents/runners/llama_workflow.py +122 -12
- pygpt_net/core/agents/runners/openai_workflow.py +2 -1
- pygpt_net/core/node_editor/types.py +13 -1
- pygpt_net/core/render/web/renderer.py +76 -11
- pygpt_net/data/config/config.json +3 -3
- pygpt_net/data/config/models.json +3 -3
- pygpt_net/data/config/presets/agent_openai_b2b.json +1 -15
- pygpt_net/data/config/presets/agent_openai_coder.json +1 -15
- pygpt_net/data/config/presets/agent_openai_evolve.json +1 -23
- pygpt_net/data/config/presets/agent_openai_planner.json +1 -21
- pygpt_net/data/config/presets/agent_openai_researcher.json +1 -21
- pygpt_net/data/config/presets/agent_openai_supervisor.json +1 -13
- pygpt_net/data/config/presets/agent_openai_writer.json +1 -15
- pygpt_net/data/config/presets/agent_supervisor.json +1 -11
- pygpt_net/data/css/style.dark.css +18 -0
- pygpt_net/data/css/style.light.css +20 -1
- pygpt_net/data/js/app/runtime.js +4 -1
- pygpt_net/data/js/app.min.js +3 -2
- pygpt_net/data/locale/locale.de.ini +2 -0
- pygpt_net/data/locale/locale.en.ini +7 -0
- pygpt_net/data/locale/locale.es.ini +2 -0
- pygpt_net/data/locale/locale.fr.ini +2 -0
- pygpt_net/data/locale/locale.it.ini +2 -0
- pygpt_net/data/locale/locale.pl.ini +3 -1
- pygpt_net/data/locale/locale.uk.ini +2 -0
- pygpt_net/data/locale/locale.zh.ini +2 -0
- pygpt_net/item/ctx.py +23 -1
- pygpt_net/js_rc.py +13 -10
- pygpt_net/provider/agents/base.py +0 -0
- pygpt_net/provider/agents/llama_index/flow_from_schema.py +0 -0
- 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 +248 -28
- 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 +17 -13
- pygpt_net/provider/agents/openai/agent_planner.py +617 -258
- pygpt_net/provider/agents/openai/agent_with_experts.py +4 -1
- pygpt_net/provider/agents/openai/agent_with_experts_feedback.py +8 -6
- pygpt_net/provider/agents/openai/agent_with_feedback.py +8 -6
- pygpt_net/provider/agents/openai/evolve.py +12 -8
- pygpt_net/provider/agents/openai/flow_from_schema.py +0 -0
- pygpt_net/provider/agents/openai/supervisor.py +292 -37
- pygpt_net/provider/api/openai/agents/response.py +1 -0
- pygpt_net/provider/api/x_ai/__init__.py +0 -0
- pygpt_net/provider/core/agent/__init__.py +0 -0
- pygpt_net/provider/core/agent/base.py +0 -0
- pygpt_net/provider/core/agent/json_file.py +0 -0
- pygpt_net/provider/core/config/patch.py +8 -0
- pygpt_net/provider/core/config/patches/patch_before_2_6_42.py +0 -0
- pygpt_net/provider/llms/base.py +0 -0
- pygpt_net/provider/llms/deepseek_api.py +0 -0
- pygpt_net/provider/llms/google.py +0 -0
- pygpt_net/provider/llms/hugging_face_api.py +0 -0
- pygpt_net/provider/llms/hugging_face_router.py +0 -0
- pygpt_net/provider/llms/mistral.py +0 -0
- pygpt_net/provider/llms/perplexity.py +0 -0
- pygpt_net/provider/llms/x_ai.py +0 -0
- pygpt_net/tools/agent_builder/tool.py +6 -0
- pygpt_net/tools/agent_builder/ui/dialogs.py +0 -41
- pygpt_net/ui/layout/toolbox/presets.py +14 -2
- pygpt_net/ui/main.py +2 -2
- pygpt_net/ui/widget/dialog/confirm.py +55 -5
- pygpt_net/ui/widget/draw/painter.py +90 -1
- pygpt_net/ui/widget/lists/preset.py +289 -25
- pygpt_net/ui/widget/node_editor/editor.py +53 -15
- pygpt_net/ui/widget/node_editor/node.py +82 -104
- pygpt_net/ui/widget/node_editor/view.py +4 -5
- pygpt_net/ui/widget/textarea/input.py +155 -21
- {pygpt_net-2.6.61.dist-info → pygpt_net-2.6.63.dist-info}/METADATA +22 -8
- {pygpt_net-2.6.61.dist-info → pygpt_net-2.6.63.dist-info}/RECORD +70 -70
- {pygpt_net-2.6.61.dist-info → pygpt_net-2.6.63.dist-info}/LICENSE +0 -0
- {pygpt_net-2.6.61.dist-info → pygpt_net-2.6.63.dist-info}/WHEEL +0 -0
- {pygpt_net-2.6.61.dist-info → pygpt_net-2.6.63.dist-info}/entry_points.txt +0 -0
|
@@ -6,10 +6,11 @@
|
|
|
6
6
|
# GitHub: https://github.com/szczyglis-dev/py-gpt #
|
|
7
7
|
# MIT License #
|
|
8
8
|
# Created By : Marcin Szczygliński #
|
|
9
|
-
# Updated Date:
|
|
9
|
+
# Updated Date: 2025.09.27 09:30:00 #
|
|
10
10
|
# ================================================== #
|
|
11
11
|
|
|
12
|
-
|
|
12
|
+
import sys
|
|
13
|
+
from PySide6.QtCore import Qt, QTimer
|
|
13
14
|
from PySide6.QtWidgets import QDialog, QLabel, QHBoxLayout, QVBoxLayout, QPushButton
|
|
14
15
|
|
|
15
16
|
from pygpt_net.utils import trans
|
|
@@ -32,6 +33,7 @@ class ConfirmDialog(QDialog):
|
|
|
32
33
|
self.setWindowTitle(trans('dialog.confirm.title'))
|
|
33
34
|
self.setWindowFlags(self.windowFlags() | Qt.WindowStaysOnTopHint) # always on top
|
|
34
35
|
|
|
36
|
+
# Buttons
|
|
35
37
|
self.window.ui.nodes['dialog.confirm.btn.yes'] = QPushButton(trans('dialog.confirm.yes'))
|
|
36
38
|
self.window.ui.nodes['dialog.confirm.btn.yes'].clicked.connect(
|
|
37
39
|
lambda: self.window.controller.dialogs.confirm.accept(self.type, self.id, self.parent_object))
|
|
@@ -40,9 +42,20 @@ class ConfirmDialog(QDialog):
|
|
|
40
42
|
self.window.ui.nodes['dialog.confirm.btn.no'].clicked.connect(
|
|
41
43
|
lambda: self.window.controller.dialogs.confirm.dismiss(self.type, self.id))
|
|
42
44
|
|
|
45
|
+
# Always make the neutral action (No/Cancel) the default/active one.
|
|
46
|
+
# This ensures Enter triggers the safe option by default.
|
|
47
|
+
self._apply_neutral_default()
|
|
48
|
+
|
|
49
|
+
# Bottom button row with platform-specific ordering
|
|
50
|
+
# Windows: affirmative on the left, neutral on the right
|
|
51
|
+
# Linux/macOS: neutral on the left, affirmative on the right
|
|
43
52
|
bottom = QHBoxLayout()
|
|
44
|
-
|
|
45
|
-
|
|
53
|
+
if self._affirmative_on_left():
|
|
54
|
+
bottom.addWidget(self.window.ui.nodes['dialog.confirm.btn.yes'])
|
|
55
|
+
bottom.addWidget(self.window.ui.nodes['dialog.confirm.btn.no'])
|
|
56
|
+
else:
|
|
57
|
+
bottom.addWidget(self.window.ui.nodes['dialog.confirm.btn.no'])
|
|
58
|
+
bottom.addWidget(self.window.ui.nodes['dialog.confirm.btn.yes'])
|
|
46
59
|
|
|
47
60
|
self.layout = QVBoxLayout()
|
|
48
61
|
self.message = QLabel("")
|
|
@@ -54,6 +67,13 @@ class ConfirmDialog(QDialog):
|
|
|
54
67
|
self.layout.addLayout(bottom)
|
|
55
68
|
self.setLayout(self.layout)
|
|
56
69
|
|
|
70
|
+
def _affirmative_on_left(self) -> bool:
|
|
71
|
+
"""
|
|
72
|
+
Decide button order depending on the platform.
|
|
73
|
+
Returns True on Windows, False otherwise (Linux/macOS).
|
|
74
|
+
"""
|
|
75
|
+
return sys.platform.startswith('win')
|
|
76
|
+
|
|
57
77
|
def closeEvent(self, event):
|
|
58
78
|
"""
|
|
59
79
|
Close event handler
|
|
@@ -61,4 +81,34 @@ class ConfirmDialog(QDialog):
|
|
|
61
81
|
:param event: close event
|
|
62
82
|
"""
|
|
63
83
|
self.window.controller.dialogs.confirm.dismiss(self.type, self.id)
|
|
64
|
-
super(ConfirmDialog, self).closeEvent(event)
|
|
84
|
+
super(ConfirmDialog, self).closeEvent(event)
|
|
85
|
+
|
|
86
|
+
def showEvent(self, event):
|
|
87
|
+
"""
|
|
88
|
+
Ensure neutral button is default/active on every show.
|
|
89
|
+
|
|
90
|
+
Using a single-shot timer defers focus/default restoration until
|
|
91
|
+
after the dialog becomes visible, which prevents focus being stolen
|
|
92
|
+
by the window manager.
|
|
93
|
+
"""
|
|
94
|
+
super(ConfirmDialog, self).showEvent(event)
|
|
95
|
+
QTimer.singleShot(0, self._apply_neutral_default)
|
|
96
|
+
|
|
97
|
+
def _apply_neutral_default(self):
|
|
98
|
+
"""
|
|
99
|
+
Set the neutral action (No/Cancel) as default and active.
|
|
100
|
+
Always called on construction and each time the dialog is shown.
|
|
101
|
+
"""
|
|
102
|
+
btn_no = self.window.ui.nodes.get('dialog.confirm.btn.no')
|
|
103
|
+
btn_yes = self.window.ui.nodes.get('dialog.confirm.btn.yes')
|
|
104
|
+
if not btn_no or not btn_yes:
|
|
105
|
+
return
|
|
106
|
+
|
|
107
|
+
# Make sure affirmative button cannot become default by leftover state
|
|
108
|
+
btn_yes.setAutoDefault(False)
|
|
109
|
+
btn_yes.setDefault(False)
|
|
110
|
+
|
|
111
|
+
# Make neutral (No/Cancel) the active default and take focus
|
|
112
|
+
btn_no.setAutoDefault(True)
|
|
113
|
+
btn_no.setDefault(True)
|
|
114
|
+
btn_no.setFocus()
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
# GitHub: https://github.com/szczyglis-dev/py-gpt #
|
|
7
7
|
# MIT License #
|
|
8
8
|
# Created By : Marcin Szczygliński #
|
|
9
|
-
# Updated Date: 2025.09.
|
|
9
|
+
# Updated Date: 2025.09.26 12:00:00 #
|
|
10
10
|
# ================================================== #
|
|
11
11
|
|
|
12
12
|
import datetime
|
|
@@ -98,6 +98,11 @@ class PainterWidget(QWidget):
|
|
|
98
98
|
self._autoScrollMinSpeed = 2 # px per tick (min)
|
|
99
99
|
self._autoScrollMaxSpeed = 18 # px per tick (max)
|
|
100
100
|
|
|
101
|
+
# Pan (middle mouse) state
|
|
102
|
+
self._panning = False
|
|
103
|
+
self._panLastGlobalPos = QPoint()
|
|
104
|
+
self._cursorBeforePan = None # store/restore cursor shape while panning
|
|
105
|
+
|
|
101
106
|
# Actions
|
|
102
107
|
self._act_undo = QAction(QIcon(":/icons/undo.svg"), trans('action.undo'), self)
|
|
103
108
|
self._act_undo.triggered.connect(self.undo)
|
|
@@ -1084,6 +1089,69 @@ class PainterWidget(QWidget):
|
|
|
1084
1089
|
if scrolled or dx != 0 or dy != 0:
|
|
1085
1090
|
self.update()
|
|
1086
1091
|
|
|
1092
|
+
# ---------- Pan (middle mouse drag) ----------
|
|
1093
|
+
|
|
1094
|
+
def _can_pan(self) -> bool:
|
|
1095
|
+
"""
|
|
1096
|
+
Return True if widget is inside a scroll area and content is scrollable.
|
|
1097
|
+
"""
|
|
1098
|
+
self._find_scroll_area()
|
|
1099
|
+
if self._scrollArea is None:
|
|
1100
|
+
return False
|
|
1101
|
+
hbar = self._scrollArea.horizontalScrollBar()
|
|
1102
|
+
vbar = self._scrollArea.verticalScrollBar()
|
|
1103
|
+
h_ok = hbar is not None and hbar.maximum() > hbar.minimum()
|
|
1104
|
+
v_ok = vbar is not None and vbar.maximum() > vbar.minimum()
|
|
1105
|
+
return h_ok or v_ok
|
|
1106
|
+
|
|
1107
|
+
def _start_pan(self, global_pos: QPoint):
|
|
1108
|
+
"""
|
|
1109
|
+
Begin view panning with middle mouse button.
|
|
1110
|
+
"""
|
|
1111
|
+
if self._panning:
|
|
1112
|
+
return
|
|
1113
|
+
self._panning = True
|
|
1114
|
+
self._panLastGlobalPos = QPoint(global_pos)
|
|
1115
|
+
# Store current cursor to restore later
|
|
1116
|
+
self._cursorBeforePan = QCursor(self.cursor())
|
|
1117
|
+
# Use a closed hand to indicate grabbing the canvas
|
|
1118
|
+
self.setCursor(QCursor(Qt.ClosedHandCursor))
|
|
1119
|
+
self.grabMouse()
|
|
1120
|
+
|
|
1121
|
+
def _update_pan(self, global_pos: QPoint):
|
|
1122
|
+
"""
|
|
1123
|
+
Update scrollbars based on mouse movement delta in global coordinates.
|
|
1124
|
+
"""
|
|
1125
|
+
if not self._panning or self._scrollArea is None:
|
|
1126
|
+
return
|
|
1127
|
+
dx = global_pos.x() - self._panLastGlobalPos.x()
|
|
1128
|
+
dy = global_pos.y() - self._panLastGlobalPos.y()
|
|
1129
|
+
self._panLastGlobalPos = QPoint(global_pos)
|
|
1130
|
+
|
|
1131
|
+
hbar = self._scrollArea.horizontalScrollBar()
|
|
1132
|
+
vbar = self._scrollArea.verticalScrollBar()
|
|
1133
|
+
|
|
1134
|
+
# Dragging the content to the right should reveal the left side -> subtract deltas
|
|
1135
|
+
if hbar is not None and hbar.maximum() > hbar.minimum():
|
|
1136
|
+
hbar.setValue(int(max(hbar.minimum(), min(hbar.maximum(), hbar.value() - dx))))
|
|
1137
|
+
if vbar is not None and vbar.maximum() > vbar.minimum():
|
|
1138
|
+
vbar.setValue(int(max(vbar.minimum(), min(vbar.maximum(), vbar.value() - dy))))
|
|
1139
|
+
|
|
1140
|
+
def _end_pan(self):
|
|
1141
|
+
"""
|
|
1142
|
+
End panning and restore previous cursor.
|
|
1143
|
+
"""
|
|
1144
|
+
if not self._panning:
|
|
1145
|
+
return
|
|
1146
|
+
self._panning = False
|
|
1147
|
+
self.releaseMouse()
|
|
1148
|
+
try:
|
|
1149
|
+
if self._cursorBeforePan is not None:
|
|
1150
|
+
# Restore previous cursor (do not guess based on mode/crop)
|
|
1151
|
+
self.setCursor(self._cursorBeforePan)
|
|
1152
|
+
finally:
|
|
1153
|
+
self._cursorBeforePan = None
|
|
1154
|
+
|
|
1087
1155
|
# ---------- Events ----------
|
|
1088
1156
|
|
|
1089
1157
|
def wheelEvent(self, event):
|
|
@@ -1109,6 +1177,14 @@ class PainterWidget(QWidget):
|
|
|
1109
1177
|
|
|
1110
1178
|
:param event: Event
|
|
1111
1179
|
"""
|
|
1180
|
+
# Middle button: start panning if scrollable
|
|
1181
|
+
if event.button() == Qt.MiddleButton:
|
|
1182
|
+
if not (self.cropping and self._selecting) and not self.drawing and self._can_pan():
|
|
1183
|
+
gp = event.globalPosition().toPoint()
|
|
1184
|
+
self._start_pan(gp)
|
|
1185
|
+
event.accept()
|
|
1186
|
+
return
|
|
1187
|
+
|
|
1112
1188
|
if event.button() == Qt.LeftButton:
|
|
1113
1189
|
self._mouseDown = True
|
|
1114
1190
|
if self.cropping:
|
|
@@ -1146,6 +1222,13 @@ class PainterWidget(QWidget):
|
|
|
1146
1222
|
|
|
1147
1223
|
:param event: Event
|
|
1148
1224
|
"""
|
|
1225
|
+
# Update panning if active
|
|
1226
|
+
if self._panning and (event.buttons() & Qt.MiddleButton):
|
|
1227
|
+
gp = event.globalPosition().toPoint()
|
|
1228
|
+
self._update_pan(gp)
|
|
1229
|
+
event.accept()
|
|
1230
|
+
return
|
|
1231
|
+
|
|
1149
1232
|
if self.cropping and self._selecting and (event.buttons() & Qt.LeftButton):
|
|
1150
1233
|
self._selectionRect = QRect(self._selectionStart, self._to_canvas_point(event.position()))
|
|
1151
1234
|
self.update()
|
|
@@ -1175,6 +1258,12 @@ class PainterWidget(QWidget):
|
|
|
1175
1258
|
|
|
1176
1259
|
:param event: Event
|
|
1177
1260
|
"""
|
|
1261
|
+
# End panning on middle button release
|
|
1262
|
+
if event.button() == Qt.MiddleButton:
|
|
1263
|
+
self._end_pan()
|
|
1264
|
+
event.accept()
|
|
1265
|
+
return
|
|
1266
|
+
|
|
1178
1267
|
if event.button() in (Qt.LeftButton, Qt.RightButton):
|
|
1179
1268
|
self._mouseDown = False
|
|
1180
1269
|
if self.cropping and self._selecting:
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
# GitHub: https://github.com/szczyglis-dev/py-gpt #
|
|
7
7
|
# MIT License #
|
|
8
8
|
# Created By : Marcin Szczygliński #
|
|
9
|
-
# Updated Date: 2025.09.26
|
|
9
|
+
# Updated Date: 2025.09.26 12:00:00 #
|
|
10
10
|
# ================================================== #
|
|
11
11
|
|
|
12
12
|
from PySide6.QtCore import QPoint, QItemSelectionModel, Qt, QEventLoop, QTimer, QMimeData
|
|
@@ -65,6 +65,7 @@ class PresetList(BaseList):
|
|
|
65
65
|
self.setDragDropMode(QAbstractItemView.NoDragDrop) # switched dynamically
|
|
66
66
|
self.setDefaultDropAction(Qt.MoveAction)
|
|
67
67
|
self.setDragDropOverwriteMode(False)
|
|
68
|
+
# We use our own visual indicator for drop position
|
|
68
69
|
self.setDropIndicatorShown(False)
|
|
69
70
|
|
|
70
71
|
self._press_pos = None
|
|
@@ -92,6 +93,18 @@ class PresetList(BaseList):
|
|
|
92
93
|
# One-shot forced selection after refresh (list of ROLE_ID)
|
|
93
94
|
self._selection_override_ids = None
|
|
94
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
|
+
|
|
95
108
|
# -------- Public helpers to protect updates --------
|
|
96
109
|
|
|
97
110
|
def begin_model_update(self):
|
|
@@ -103,26 +116,135 @@ class PresetList(BaseList):
|
|
|
103
116
|
"""Re-enable interaction after model/view rebuild is complete."""
|
|
104
117
|
self.setEnabled(True)
|
|
105
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)
|
|
106
126
|
|
|
107
127
|
# ---------------------------------------------------
|
|
108
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
|
+
|
|
109
229
|
def set_dnd_enabled(self, enabled: bool):
|
|
110
230
|
"""
|
|
111
231
|
Toggle DnD behaviour at runtime.
|
|
112
232
|
Using DragDrop (not InternalMove) to avoid implicit Qt reordering.
|
|
233
|
+
We also disable the native drop indicator and render our own line.
|
|
113
234
|
"""
|
|
114
235
|
self._dnd_enabled = bool(enabled)
|
|
115
236
|
if self._dnd_enabled:
|
|
116
237
|
self.setDragEnabled(True)
|
|
117
238
|
self.setAcceptDrops(True)
|
|
118
239
|
self.setDragDropMode(QAbstractItemView.DragDrop)
|
|
119
|
-
self.setDropIndicatorShown(
|
|
240
|
+
self.setDropIndicatorShown(False) # use custom indicator
|
|
120
241
|
else:
|
|
121
242
|
self.setDragEnabled(False)
|
|
122
243
|
self.setAcceptDrops(False)
|
|
123
244
|
self.setDragDropMode(QAbstractItemView.NoDragDrop)
|
|
124
245
|
self.setDropIndicatorShown(False)
|
|
125
246
|
self.unsetCursor()
|
|
247
|
+
self._clear_drop_indicator() # ensure clean state
|
|
126
248
|
|
|
127
249
|
def backup_selection(self):
|
|
128
250
|
"""
|
|
@@ -186,12 +308,21 @@ class PresetList(BaseList):
|
|
|
186
308
|
return
|
|
187
309
|
preset_id = index.data(self.ROLE_ID)
|
|
188
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
|
|
189
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)
|
|
190
318
|
self.selection = self.selectionModel().selection()
|
|
191
319
|
return
|
|
192
320
|
row = index.row()
|
|
193
321
|
if row >= 0:
|
|
322
|
+
self._freeze_scroll(300)
|
|
194
323
|
self.window.controller.presets.select(row)
|
|
324
|
+
QTimer.singleShot(0, self._apply_pending_refocus)
|
|
325
|
+
QTimer.singleShot(50, self._apply_pending_refocus)
|
|
195
326
|
self.selection = self.selectionModel().selection()
|
|
196
327
|
|
|
197
328
|
def dblclick(self, val):
|
|
@@ -504,16 +635,13 @@ class PresetList(BaseList):
|
|
|
504
635
|
|
|
505
636
|
self.store_scroll_position()
|
|
506
637
|
|
|
507
|
-
|
|
508
|
-
self.setDropIndicatorShown(False)
|
|
638
|
+
# Use custom indicator only; do not re-enable native one here
|
|
509
639
|
self.setUpdatesEnabled(False)
|
|
510
640
|
try:
|
|
511
641
|
self.window.controller.presets.update_list()
|
|
512
642
|
self.restore_scroll_position()
|
|
513
643
|
finally:
|
|
514
644
|
self.setUpdatesEnabled(True)
|
|
515
|
-
if di_prev and self._dnd_enabled:
|
|
516
|
-
self.setDropIndicatorShown(True)
|
|
517
645
|
|
|
518
646
|
# Clear helpers for context menu (layout will consume _selection_override_ids)
|
|
519
647
|
self._ctx_menu_original_ids = None
|
|
@@ -556,6 +684,136 @@ class PresetList(BaseList):
|
|
|
556
684
|
moved_id = self._reorder_and_persist(from_row, to_row)
|
|
557
685
|
self._refresh_after_order_change(moved_id, follow_selection=False)
|
|
558
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
|
+
|
|
559
817
|
# ----------------------------
|
|
560
818
|
# Mouse / DnD events
|
|
561
819
|
# ----------------------------
|
|
@@ -580,6 +838,8 @@ class PresetList(BaseList):
|
|
|
580
838
|
index = self.indexAt(self._mouse_event_point(event))
|
|
581
839
|
if not index.isValid():
|
|
582
840
|
return
|
|
841
|
+
# Freeze scroll for a moment to prevent jumps caused by selection-triggered refresh
|
|
842
|
+
self._freeze_scroll(250)
|
|
583
843
|
if self._dnd_enabled:
|
|
584
844
|
sel_model = self.selectionModel()
|
|
585
845
|
self._press_backup_selection = list(sel_model.selectedIndexes())
|
|
@@ -657,6 +917,7 @@ class PresetList(BaseList):
|
|
|
657
917
|
|
|
658
918
|
self._dragging = True
|
|
659
919
|
self.setCursor(QCursor(Qt.ClosedHandCursor))
|
|
920
|
+
# Let base class proceed; it will trigger startDrag when needed.
|
|
660
921
|
super().mouseMoveEvent(event)
|
|
661
922
|
|
|
662
923
|
def mouseReleaseEvent(self, event):
|
|
@@ -666,12 +927,18 @@ class PresetList(BaseList):
|
|
|
666
927
|
try:
|
|
667
928
|
if self._dnd_enabled and event.button() == Qt.LeftButton:
|
|
668
929
|
self.unsetCursor()
|
|
930
|
+
self._clear_drop_indicator()
|
|
669
931
|
if not self._dragging:
|
|
670
932
|
idx = self.indexAt(self._mouse_event_point(event))
|
|
671
933
|
if idx.isValid():
|
|
672
934
|
pid = idx.data(self.ROLE_ID)
|
|
673
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
|
|
674
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)
|
|
675
942
|
else:
|
|
676
943
|
self.setCurrentIndex(idx)
|
|
677
944
|
self.window.controller.presets.select(idx.row())
|
|
@@ -693,12 +960,16 @@ class PresetList(BaseList):
|
|
|
693
960
|
return
|
|
694
961
|
event.setDropAction(Qt.MoveAction)
|
|
695
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))
|
|
696
966
|
|
|
697
967
|
def dragLeaveEvent(self, event):
|
|
698
968
|
if self._model_updating:
|
|
699
969
|
event.ignore()
|
|
700
970
|
return
|
|
701
971
|
self.unsetCursor()
|
|
972
|
+
self._clear_drop_indicator()
|
|
702
973
|
super().dragLeaveEvent(event)
|
|
703
974
|
|
|
704
975
|
def dragMoveEvent(self, event):
|
|
@@ -707,7 +978,6 @@ class PresetList(BaseList):
|
|
|
707
978
|
return
|
|
708
979
|
if not self._dnd_enabled:
|
|
709
980
|
return
|
|
710
|
-
event.setDropAction(Qt.MoveAction)
|
|
711
981
|
|
|
712
982
|
pos = self._mouse_event_point(event)
|
|
713
983
|
idx = self.indexAt(pos)
|
|
@@ -715,9 +985,17 @@ class PresetList(BaseList):
|
|
|
715
985
|
if idx.isValid() and idx.row() == 0:
|
|
716
986
|
rect = self.visualRect(idx)
|
|
717
987
|
if pos.y() <= rect.center().y():
|
|
988
|
+
self._clear_drop_indicator()
|
|
718
989
|
event.ignore()
|
|
719
990
|
return
|
|
991
|
+
|
|
992
|
+
# Let base class process autoscroll and internal geometry first
|
|
993
|
+
event.setDropAction(Qt.MoveAction)
|
|
720
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)
|
|
721
999
|
|
|
722
1000
|
def dropEvent(self, event):
|
|
723
1001
|
"""
|
|
@@ -746,26 +1024,11 @@ class PresetList(BaseList):
|
|
|
746
1024
|
event.ignore()
|
|
747
1025
|
self.unsetCursor()
|
|
748
1026
|
self._drag_selection_applied = False
|
|
1027
|
+
self._clear_drop_indicator()
|
|
749
1028
|
return
|
|
750
1029
|
|
|
751
|
-
# Target row
|
|
752
|
-
|
|
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
|
|
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))
|
|
769
1032
|
|
|
770
1033
|
moved_id = self._reorder_and_persist(from_row, to_row)
|
|
771
1034
|
|
|
@@ -778,6 +1041,7 @@ class PresetList(BaseList):
|
|
|
778
1041
|
event.acceptProposedAction()
|
|
779
1042
|
self.unsetCursor()
|
|
780
1043
|
self._drag_selection_applied = False
|
|
1044
|
+
self._clear_drop_indicator()
|
|
781
1045
|
|
|
782
1046
|
# ----------------------------
|
|
783
1047
|
# Legacy helper (not used in new path)
|