pygpt-net 2.6.66__py3-none-any.whl → 2.7.0__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 +18 -0
- pygpt_net/__init__.py +3 -3
- pygpt_net/controller/assistant/assistant.py +13 -8
- pygpt_net/controller/assistant/batch.py +29 -15
- pygpt_net/controller/assistant/files.py +19 -14
- pygpt_net/controller/assistant/store.py +63 -41
- pygpt_net/controller/attachment/attachment.py +45 -35
- pygpt_net/controller/chat/attachment.py +50 -39
- pygpt_net/controller/config/field/dictionary.py +26 -14
- pygpt_net/controller/config/field/textarea.py +2 -2
- pygpt_net/controller/ctx/common.py +27 -17
- pygpt_net/controller/ctx/ctx.py +182 -101
- pygpt_net/controller/dialogs/info.py +2 -2
- pygpt_net/controller/files/files.py +101 -41
- pygpt_net/controller/idx/indexer.py +87 -31
- pygpt_net/controller/kernel/kernel.py +13 -2
- pygpt_net/controller/media/media.py +29 -1
- pygpt_net/controller/mode/mode.py +3 -3
- pygpt_net/controller/model/editor.py +141 -21
- pygpt_net/controller/model/importer.py +153 -54
- pygpt_net/controller/painter/painter.py +2 -2
- pygpt_net/controller/presets/experts.py +68 -15
- pygpt_net/controller/presets/presets.py +72 -36
- pygpt_net/controller/settings/editor.py +25 -1
- pygpt_net/controller/settings/profile.py +76 -35
- pygpt_net/controller/settings/workdir.py +70 -39
- pygpt_net/core/assistants/files.py +20 -18
- pygpt_net/core/filesystem/actions.py +111 -10
- pygpt_net/core/filesystem/filesystem.py +2 -1
- pygpt_net/core/idx/idx.py +12 -11
- pygpt_net/core/idx/worker.py +13 -1
- pygpt_net/core/models/models.py +4 -4
- pygpt_net/core/profile/profile.py +13 -3
- pygpt_net/core/types/image.py +10 -1
- pygpt_net/core/video/video.py +43 -3
- pygpt_net/data/config/config.json +3 -3
- pygpt_net/data/config/models.json +25 -14
- pygpt_net/data/css/style.dark.css +39 -1
- pygpt_net/data/css/style.light.css +39 -1
- pygpt_net/data/locale/locale.de.ini +4 -1
- pygpt_net/data/locale/locale.en.ini +4 -1
- pygpt_net/data/locale/locale.es.ini +4 -1
- pygpt_net/data/locale/locale.fr.ini +4 -1
- pygpt_net/data/locale/locale.it.ini +4 -1
- pygpt_net/data/locale/locale.pl.ini +5 -2
- pygpt_net/data/locale/locale.uk.ini +4 -1
- pygpt_net/data/locale/locale.zh.ini +4 -1
- pygpt_net/item/model.py +1 -1
- pygpt_net/provider/api/openai/__init__.py +4 -2
- pygpt_net/provider/api/openai/video.py +2 -2
- pygpt_net/provider/core/config/patch.py +9 -1
- pygpt_net/provider/core/model/patch.py +26 -1
- pygpt_net/tools/image_viewer/tool.py +17 -0
- pygpt_net/tools/text_editor/tool.py +9 -0
- pygpt_net/ui/__init__.py +2 -2
- pygpt_net/ui/dialog/models.py +10 -1
- pygpt_net/ui/layout/ctx/ctx_list.py +16 -6
- pygpt_net/ui/layout/toolbox/video.py +14 -6
- pygpt_net/ui/main.py +3 -1
- pygpt_net/ui/widget/calendar/select.py +3 -3
- pygpt_net/ui/widget/filesystem/explorer.py +1082 -142
- pygpt_net/ui/widget/lists/assistant.py +185 -24
- pygpt_net/ui/widget/lists/assistant_store.py +245 -42
- pygpt_net/ui/widget/lists/attachment.py +230 -47
- pygpt_net/ui/widget/lists/attachment_ctx.py +189 -33
- pygpt_net/ui/widget/lists/base_list_combo.py +2 -2
- pygpt_net/ui/widget/lists/context.py +1253 -70
- pygpt_net/ui/widget/lists/experts.py +110 -8
- pygpt_net/ui/widget/lists/model_editor.py +217 -14
- pygpt_net/ui/widget/lists/model_importer.py +125 -6
- pygpt_net/ui/widget/lists/preset.py +460 -71
- pygpt_net/ui/widget/lists/profile.py +149 -27
- pygpt_net/ui/widget/lists/uploaded.py +230 -38
- pygpt_net/ui/widget/option/combo.py +1046 -32
- pygpt_net/ui/widget/option/dictionary.py +35 -7
- pygpt_net/ui/widget/option/input.py +3 -1
- {pygpt_net-2.6.66.dist-info → pygpt_net-2.7.0.dist-info}/METADATA +20 -57
- {pygpt_net-2.6.66.dist-info → pygpt_net-2.7.0.dist-info}/RECORD +81 -81
- {pygpt_net-2.6.66.dist-info → pygpt_net-2.7.0.dist-info}/LICENSE +0 -0
- {pygpt_net-2.6.66.dist-info → pygpt_net-2.7.0.dist-info}/WHEEL +0 -0
- {pygpt_net-2.6.66.dist-info → pygpt_net-2.7.0.dist-info}/entry_points.txt +0 -0
|
@@ -6,16 +6,17 @@
|
|
|
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.12.28 00:00:00 #
|
|
10
10
|
# ================================================== #
|
|
11
11
|
|
|
12
12
|
import datetime
|
|
13
13
|
import functools
|
|
14
|
+
from typing import Union
|
|
14
15
|
|
|
15
16
|
from PySide6 import QtWidgets, QtCore, QtGui
|
|
16
|
-
from PySide6.QtCore import Qt, QPoint, QItemSelectionModel
|
|
17
|
-
from PySide6.QtGui import QIcon, QColor, QPixmap, QStandardItem
|
|
18
|
-
from PySide6.QtWidgets import QMenu
|
|
17
|
+
from PySide6.QtCore import Qt, QPoint, QItemSelectionModel, QPersistentModelIndex
|
|
18
|
+
from PySide6.QtGui import QIcon, QColor, QPixmap, QStandardItem, QDrag
|
|
19
|
+
from PySide6.QtWidgets import QMenu, QAbstractItemView, QFrame
|
|
19
20
|
|
|
20
21
|
from .base import BaseList
|
|
21
22
|
from pygpt_net.utils import trans
|
|
@@ -48,6 +49,19 @@ class ContextList(BaseList):
|
|
|
48
49
|
}
|
|
49
50
|
self._color_icon_cache = {}
|
|
50
51
|
|
|
52
|
+
# Multi-select configuration and guards
|
|
53
|
+
# - ExtendedSelection enables Ctrl/Shift based multi-selection
|
|
54
|
+
# - _suppress_item_click prevents "business click" side-effects during virtual multi-select
|
|
55
|
+
self.setSelectionMode(QAbstractItemView.ExtendedSelection)
|
|
56
|
+
self.setSelectionBehavior(QAbstractItemView.SelectRows)
|
|
57
|
+
self._suppress_item_click = False
|
|
58
|
+
|
|
59
|
+
# Track last selection target type to keep selection homogeneous (groups vs items)
|
|
60
|
+
self._last_selection_target_is_group = None
|
|
61
|
+
|
|
62
|
+
# Force-next-click activation when we collapse multi-selection with a plain click
|
|
63
|
+
self._force_single_click_index: QPersistentModelIndex | None = None
|
|
64
|
+
|
|
51
65
|
# Use a custom delegate for labels/pinned/attachment indicators and group border indicator
|
|
52
66
|
# Pass both: attachment icon and pin icon (pin2.svg) for pinned indicator rendering
|
|
53
67
|
self.setItemDelegate(ImportantItemDelegate(self, self._icons['attachment'], self._icons['pin']))
|
|
@@ -68,12 +82,100 @@ class ContextList(BaseList):
|
|
|
68
82
|
# Safe no-op if the underlying view does not support setIndentation
|
|
69
83
|
pass
|
|
70
84
|
|
|
85
|
+
# Persist expanded state also when user uses the disclosure arrow or programmatic expand/collapse
|
|
86
|
+
self._connect_expand_collapse_signals()
|
|
87
|
+
|
|
71
88
|
self._loading_more = False # guard to avoid multiple triggers while updating
|
|
72
89
|
try:
|
|
73
90
|
self.verticalScrollBar().valueChanged.connect(self._on_vertical_scroll)
|
|
74
91
|
except Exception:
|
|
75
92
|
pass # safe no-op if view doesn't expose verticalScrollBar
|
|
76
93
|
|
|
94
|
+
# Keep selection homogeneous using selectionChanged pruning as a safety net
|
|
95
|
+
try:
|
|
96
|
+
self.selectionModel().selectionChanged.connect(self._on_selection_changed)
|
|
97
|
+
except Exception:
|
|
98
|
+
# Will be connected by the framework once model/selection model is available
|
|
99
|
+
pass
|
|
100
|
+
|
|
101
|
+
# Scroll preservation guards for destructive model changes (like delete/move)
|
|
102
|
+
# They ensure the view does not jump after removing rows and anchor to RMB target when used.
|
|
103
|
+
self._scroll_guard_active = False
|
|
104
|
+
self._deletion_initiated = False
|
|
105
|
+
self._pre_update_scroll_value = 0
|
|
106
|
+
self._connected_model = None
|
|
107
|
+
self._model_signals_connected = False
|
|
108
|
+
self._context_menu_anchor_index: QPersistentModelIndex | None = None
|
|
109
|
+
self._context_menu_anchor_scroll_value: int | None = None
|
|
110
|
+
self._connect_model_signals_safely()
|
|
111
|
+
|
|
112
|
+
# Drag & Drop: enable internal drag of items onto group rows and a visual drop highlight
|
|
113
|
+
self._drag_mime = "application/x-pygpt-ctx-ids"
|
|
114
|
+
self._drop_highlight_index: QPersistentModelIndex | None = None
|
|
115
|
+
|
|
116
|
+
self.setDragEnabled(True)
|
|
117
|
+
self.setAcceptDrops(True)
|
|
118
|
+
self.setDropIndicatorShown(False)
|
|
119
|
+
try:
|
|
120
|
+
# Available on QAbstractItemView in Qt6
|
|
121
|
+
self.setDefaultDropAction(Qt.MoveAction)
|
|
122
|
+
except Exception:
|
|
123
|
+
pass
|
|
124
|
+
|
|
125
|
+
# Drop target highlight overlay (same style as requested)
|
|
126
|
+
self._dir_highlight = QFrame(self.viewport())
|
|
127
|
+
self._dir_highlight.setObjectName("drop-dir-highlight")
|
|
128
|
+
self._dir_highlight.setFrameShape(QFrame.NoFrame)
|
|
129
|
+
self._dir_highlight.setStyleSheet(
|
|
130
|
+
"#drop-dir-highlight { border: 2px solid rgba(40,120,255,0.95); border-radius: 3px; "
|
|
131
|
+
"background-color: rgba(40,120,255,0.10); }"
|
|
132
|
+
)
|
|
133
|
+
self._dir_highlight.setAttribute(Qt.WA_TransparentForMouseEvents, True)
|
|
134
|
+
self._dir_highlight.hide()
|
|
135
|
+
|
|
136
|
+
# Manual multi-select drag detection state
|
|
137
|
+
self._drag_press_pos = QtCore.QPoint()
|
|
138
|
+
self._drag_press_index: QPersistentModelIndex | None = None
|
|
139
|
+
self._drag_pending_from_multi = False
|
|
140
|
+
|
|
141
|
+
# Force-scroll runtime flags
|
|
142
|
+
self._force_scroll_lock_active = False # when True, blocks other auto-scrolls (guard restores)
|
|
143
|
+
self._bypass_guard_once = False # one-shot bypass for scroll guard
|
|
144
|
+
|
|
145
|
+
def _connect_expand_collapse_signals(self):
|
|
146
|
+
"""
|
|
147
|
+
Connect view expand/collapse signals to maintain persistent expanded_items state.
|
|
148
|
+
"""
|
|
149
|
+
try:
|
|
150
|
+
if hasattr(self, 'expanded'):
|
|
151
|
+
self.expanded.connect(self._on_group_expanded)
|
|
152
|
+
if hasattr(self, 'collapsed'):
|
|
153
|
+
self.collapsed.connect(self._on_group_collapsed)
|
|
154
|
+
except Exception:
|
|
155
|
+
pass
|
|
156
|
+
|
|
157
|
+
def _on_group_expanded(self, index: QtCore.QModelIndex):
|
|
158
|
+
"""
|
|
159
|
+
Remember expanded group id when group is expanded from UI or programmatically.
|
|
160
|
+
"""
|
|
161
|
+
try:
|
|
162
|
+
item = self._model.itemFromIndex(index)
|
|
163
|
+
if isinstance(item, GroupItem) and hasattr(item, "id"):
|
|
164
|
+
self.expanded_items.add(item.id)
|
|
165
|
+
except Exception:
|
|
166
|
+
pass
|
|
167
|
+
|
|
168
|
+
def _on_group_collapsed(self, index: QtCore.QModelIndex):
|
|
169
|
+
"""
|
|
170
|
+
Forget group id when collapsed.
|
|
171
|
+
"""
|
|
172
|
+
try:
|
|
173
|
+
item = self._model.itemFromIndex(index)
|
|
174
|
+
if isinstance(item, GroupItem) and hasattr(item, "id"):
|
|
175
|
+
self.expanded_items.discard(item.id)
|
|
176
|
+
except Exception:
|
|
177
|
+
pass
|
|
178
|
+
|
|
77
179
|
def _on_vertical_scroll(self, value: int):
|
|
78
180
|
"""
|
|
79
181
|
Trigger infinite scroll: when scrollbar reaches bottom, request the next page.
|
|
@@ -92,6 +194,290 @@ class ContextList(BaseList):
|
|
|
92
194
|
# Release the guard shortly after model updates
|
|
93
195
|
QtCore.QTimer.singleShot(250, lambda: setattr(self, "_loading_more", False))
|
|
94
196
|
|
|
197
|
+
def _connect_model_signals_safely(self):
|
|
198
|
+
"""
|
|
199
|
+
Connect model change signals once and keep track of the connected instance.
|
|
200
|
+
"""
|
|
201
|
+
try:
|
|
202
|
+
model = self._model if self._model is not None else self.model()
|
|
203
|
+
except Exception:
|
|
204
|
+
model = self.model()
|
|
205
|
+
|
|
206
|
+
if model is None:
|
|
207
|
+
return
|
|
208
|
+
|
|
209
|
+
if self._connected_model is model and self._model_signals_connected:
|
|
210
|
+
return
|
|
211
|
+
|
|
212
|
+
# Disconnect from previous model if needed
|
|
213
|
+
if self._connected_model is not None and self._connected_model is not model:
|
|
214
|
+
try:
|
|
215
|
+
self._connected_model.rowsAboutToBeRemoved.disconnect(self._on_rows_about_to_be_removed)
|
|
216
|
+
except Exception:
|
|
217
|
+
pass
|
|
218
|
+
try:
|
|
219
|
+
self._connected_model.rowsRemoved.disconnect(self._on_rows_removed)
|
|
220
|
+
except Exception:
|
|
221
|
+
pass
|
|
222
|
+
try:
|
|
223
|
+
self._connected_model.modelAboutToBeReset.disconnect(self._on_model_about_to_be_reset)
|
|
224
|
+
except Exception:
|
|
225
|
+
pass
|
|
226
|
+
try:
|
|
227
|
+
self._connected_model.modelReset.disconnect(self._on_model_reset)
|
|
228
|
+
except Exception:
|
|
229
|
+
pass
|
|
230
|
+
try:
|
|
231
|
+
self._connected_model.layoutAboutToBeChanged.disconnect(self._on_layout_about_to_change)
|
|
232
|
+
except Exception:
|
|
233
|
+
pass
|
|
234
|
+
try:
|
|
235
|
+
self._connected_model.layoutChanged.disconnect(self._on_layout_changed)
|
|
236
|
+
except Exception:
|
|
237
|
+
pass
|
|
238
|
+
|
|
239
|
+
# Connect to current model
|
|
240
|
+
try:
|
|
241
|
+
model.rowsAboutToBeRemoved.connect(self._on_rows_about_to_be_removed)
|
|
242
|
+
except Exception:
|
|
243
|
+
pass
|
|
244
|
+
try:
|
|
245
|
+
model.rowsRemoved.connect(self._on_rows_removed)
|
|
246
|
+
except Exception:
|
|
247
|
+
pass
|
|
248
|
+
try:
|
|
249
|
+
model.modelAboutToBeReset.connect(self._on_model_about_to_be_reset)
|
|
250
|
+
except Exception:
|
|
251
|
+
pass
|
|
252
|
+
try:
|
|
253
|
+
model.modelReset.connect(self._on_model_reset)
|
|
254
|
+
except Exception:
|
|
255
|
+
pass
|
|
256
|
+
try:
|
|
257
|
+
model.layoutAboutToBeChanged.connect(self._on_layout_about_to_change)
|
|
258
|
+
except Exception:
|
|
259
|
+
pass
|
|
260
|
+
try:
|
|
261
|
+
model.layoutChanged.connect(self._on_layout_changed)
|
|
262
|
+
except Exception:
|
|
263
|
+
pass
|
|
264
|
+
|
|
265
|
+
self._connected_model = model
|
|
266
|
+
self._model_signals_connected = True
|
|
267
|
+
|
|
268
|
+
def setModel(self, model):
|
|
269
|
+
"""
|
|
270
|
+
Ensure model signals are (re)connected whenever the view's model is replaced.
|
|
271
|
+
"""
|
|
272
|
+
super().setModel(model)
|
|
273
|
+
self._connect_model_signals_safely()
|
|
274
|
+
|
|
275
|
+
def _activate_scroll_guard(self, reason: str = "", override_value: int | None = None):
|
|
276
|
+
"""
|
|
277
|
+
Capture current (or overridden) scroll position to restore it after a destructive update.
|
|
278
|
+
If override_value is provided, it will be used as the anchor scroll position.
|
|
279
|
+
"""
|
|
280
|
+
if self._scroll_guard_active:
|
|
281
|
+
if override_value is not None:
|
|
282
|
+
try:
|
|
283
|
+
self._pre_update_scroll_value = int(override_value)
|
|
284
|
+
self.set_pending_v_scroll(self._pre_update_scroll_value)
|
|
285
|
+
except Exception:
|
|
286
|
+
pass
|
|
287
|
+
return
|
|
288
|
+
try:
|
|
289
|
+
sb = self.verticalScrollBar()
|
|
290
|
+
if sb is None:
|
|
291
|
+
return
|
|
292
|
+
val = int(override_value) if override_value is not None else sb.value()
|
|
293
|
+
self._pre_update_scroll_value = val
|
|
294
|
+
self.set_pending_v_scroll(val)
|
|
295
|
+
self._scroll_guard_active = True
|
|
296
|
+
except Exception:
|
|
297
|
+
self._scroll_guard_active = True
|
|
298
|
+
self._pre_update_scroll_value = 0
|
|
299
|
+
|
|
300
|
+
def _schedule_scroll_restore(self):
|
|
301
|
+
"""
|
|
302
|
+
Restore captured scroll position after the model/layout change settles.
|
|
303
|
+
A short cascade of timers is used to win potential late scrollTo() calls.
|
|
304
|
+
"""
|
|
305
|
+
if not self._scroll_guard_active:
|
|
306
|
+
return
|
|
307
|
+
|
|
308
|
+
def apply():
|
|
309
|
+
# When force-scroll lock is active, skip guard-driven restoration to avoid bouncing
|
|
310
|
+
if getattr(self, "_force_scroll_lock_active", False):
|
|
311
|
+
return
|
|
312
|
+
try:
|
|
313
|
+
sb = self.verticalScrollBar()
|
|
314
|
+
if sb is None:
|
|
315
|
+
return
|
|
316
|
+
target = min(self._pre_update_scroll_value, sb.maximum())
|
|
317
|
+
sb.setValue(target)
|
|
318
|
+
except Exception:
|
|
319
|
+
pass
|
|
320
|
+
|
|
321
|
+
# Apply several times to outlast any post-update scrolls triggered by selection changes
|
|
322
|
+
QtCore.QTimer.singleShot(0, apply)
|
|
323
|
+
QtCore.QTimer.singleShot(25, apply)
|
|
324
|
+
QtCore.QTimer.singleShot(75, apply)
|
|
325
|
+
QtCore.QTimer.singleShot(150, apply)
|
|
326
|
+
QtCore.QTimer.singleShot(300, apply)
|
|
327
|
+
QtCore.QTimer.singleShot(600, apply)
|
|
328
|
+
QtCore.QTimer.singleShot(750, self._clear_scroll_guard)
|
|
329
|
+
|
|
330
|
+
def _clear_scroll_guard(self):
|
|
331
|
+
"""
|
|
332
|
+
Clear guard flags after restoration completed.
|
|
333
|
+
"""
|
|
334
|
+
self._scroll_guard_active = False
|
|
335
|
+
self._deletion_initiated = False
|
|
336
|
+
self._context_menu_anchor_index = None
|
|
337
|
+
self._context_menu_anchor_scroll_value = None
|
|
338
|
+
# Clear BaseList pending values if any were set
|
|
339
|
+
try:
|
|
340
|
+
self.clear_pending_scroll()
|
|
341
|
+
except Exception:
|
|
342
|
+
pass
|
|
343
|
+
|
|
344
|
+
# Model signal handlers
|
|
345
|
+
|
|
346
|
+
def _on_rows_about_to_be_removed(self, parent, start, end):
|
|
347
|
+
"""
|
|
348
|
+
Rows are going to be removed; capture current scroll to preserve viewport.
|
|
349
|
+
Prefer context menu anchor value if present.
|
|
350
|
+
"""
|
|
351
|
+
anchor_val = self._context_menu_anchor_scroll_value
|
|
352
|
+
self._activate_scroll_guard("rowsAboutToBeRemoved", anchor_val)
|
|
353
|
+
|
|
354
|
+
def _on_rows_removed(self, parent, start, end):
|
|
355
|
+
"""
|
|
356
|
+
Rows removed; schedule scroll restoration.
|
|
357
|
+
"""
|
|
358
|
+
if self._scroll_guard_active or self._deletion_initiated:
|
|
359
|
+
self._schedule_scroll_restore()
|
|
360
|
+
|
|
361
|
+
def _on_model_about_to_be_reset(self):
|
|
362
|
+
"""
|
|
363
|
+
Model reset incoming. If it follows a delete operation, capture scroll now.
|
|
364
|
+
"""
|
|
365
|
+
if self._deletion_initiated or self._scroll_guard_active:
|
|
366
|
+
anchor_val = self._context_menu_anchor_scroll_value
|
|
367
|
+
self._activate_scroll_guard("modelAboutToBeReset", anchor_val)
|
|
368
|
+
|
|
369
|
+
def _on_model_reset(self):
|
|
370
|
+
"""
|
|
371
|
+
Model has been reset; restore scroll if we armed the guard.
|
|
372
|
+
"""
|
|
373
|
+
if self._scroll_guard_active or self._deletion_initiated:
|
|
374
|
+
self._schedule_scroll_restore()
|
|
375
|
+
|
|
376
|
+
def _on_layout_about_to_change(self):
|
|
377
|
+
"""
|
|
378
|
+
Layout change incoming; if it is a consequence of delete, capture scroll.
|
|
379
|
+
"""
|
|
380
|
+
if self._deletion_initiated:
|
|
381
|
+
anchor_val = self._context_menu_anchor_scroll_value
|
|
382
|
+
self._activate_scroll_guard("layoutAboutToBeChanged", anchor_val)
|
|
383
|
+
|
|
384
|
+
def _on_layout_changed(self):
|
|
385
|
+
"""
|
|
386
|
+
Layout changed; restore scroll if guard is active.
|
|
387
|
+
"""
|
|
388
|
+
if self._scroll_guard_active or self._deletion_initiated:
|
|
389
|
+
self._schedule_scroll_restore()
|
|
390
|
+
|
|
391
|
+
def scrollTo(self, index, hint=QAbstractItemView.EnsureVisible):
|
|
392
|
+
"""
|
|
393
|
+
Block automatic scrolling requests while scroll guard is active
|
|
394
|
+
(e.g., selection changes executed by controller after delete).
|
|
395
|
+
Allow one-shot bypass for explicit force-scroll.
|
|
396
|
+
"""
|
|
397
|
+
if self._deletion_initiated:
|
|
398
|
+
return
|
|
399
|
+
if self._scroll_guard_active or self._deletion_initiated:
|
|
400
|
+
if not getattr(self, "_bypass_guard_once", False):
|
|
401
|
+
return
|
|
402
|
+
super().scrollTo(index, hint)
|
|
403
|
+
self._bypass_guard_once = False # consume bypass token
|
|
404
|
+
|
|
405
|
+
def _is_index_visible_in_viewport(self, index: QtCore.QModelIndex, fully: bool = False) -> bool:
|
|
406
|
+
"""
|
|
407
|
+
Returns True if the given index is currently visible in the viewport.
|
|
408
|
+
When 'fully' is True, requires the whole rect to fit inside the viewport.
|
|
409
|
+
"""
|
|
410
|
+
try:
|
|
411
|
+
if not index or not index.isValid():
|
|
412
|
+
return False
|
|
413
|
+
rect = self.visualRect(index)
|
|
414
|
+
if not rect.isValid():
|
|
415
|
+
return False
|
|
416
|
+
vp = self.viewport().rect()
|
|
417
|
+
return vp.contains(rect) if fully else rect.intersects(vp)
|
|
418
|
+
except Exception:
|
|
419
|
+
return False
|
|
420
|
+
|
|
421
|
+
def force_scroll_to_current(self, center: bool = True, duration_ms: int = 850):
|
|
422
|
+
"""
|
|
423
|
+
Force-scroll to the current selection/index:
|
|
424
|
+
- Temporarily blocks any auto scroll (guard-based restore),
|
|
425
|
+
- One-shot bypasses scroll guard so this call cannot be blocked,
|
|
426
|
+
- Expands ancestors so the target is visible in tree views,
|
|
427
|
+
- If the target row is already visible in the current viewport, does nothing.
|
|
428
|
+
|
|
429
|
+
:param center: when True, centers row; otherwise ensures it's just visible
|
|
430
|
+
:param duration_ms: how long to block competing auto-scrolls (in ms)
|
|
431
|
+
"""
|
|
432
|
+
try:
|
|
433
|
+
index = self.currentIndex()
|
|
434
|
+
if not index or not index.isValid():
|
|
435
|
+
# Fallback to first selected row if current index is invalid
|
|
436
|
+
sel = self.selectionModel()
|
|
437
|
+
if sel:
|
|
438
|
+
rows = sel.selectedRows(0)
|
|
439
|
+
if rows:
|
|
440
|
+
index = rows[0]
|
|
441
|
+
if not index or not index.isValid():
|
|
442
|
+
return
|
|
443
|
+
|
|
444
|
+
# If row is already visible with the current scroll, skip any scrolling
|
|
445
|
+
if self._is_index_visible_in_viewport(index):
|
|
446
|
+
return
|
|
447
|
+
|
|
448
|
+
# Expand ancestors so index can become visible if it is currently hidden under a collapsed parent
|
|
449
|
+
parent = index.parent()
|
|
450
|
+
while parent.isValid():
|
|
451
|
+
try:
|
|
452
|
+
if hasattr(self, "isExpanded") and not self.isExpanded(parent):
|
|
453
|
+
self.setExpanded(parent, True)
|
|
454
|
+
except Exception:
|
|
455
|
+
break
|
|
456
|
+
parent = parent.parent()
|
|
457
|
+
|
|
458
|
+
# Check visibility again after potential expansion to avoid unnecessary scroll
|
|
459
|
+
if self._is_index_visible_in_viewport(index):
|
|
460
|
+
return
|
|
461
|
+
|
|
462
|
+
# Arm force lock and guard bypass
|
|
463
|
+
self._force_scroll_lock_active = True
|
|
464
|
+
self._bypass_guard_once = True
|
|
465
|
+
|
|
466
|
+
hint = QAbstractItemView.PositionAtCenter if center else QAbstractItemView.EnsureVisible
|
|
467
|
+
self.scrollTo(index, hint)
|
|
468
|
+
|
|
469
|
+
# Auto-release the force lock after the requested duration
|
|
470
|
+
try:
|
|
471
|
+
QtCore.QTimer.singleShot(
|
|
472
|
+
max(0, int(duration_ms)),
|
|
473
|
+
lambda: setattr(self, "_force_scroll_lock_active", False)
|
|
474
|
+
)
|
|
475
|
+
except Exception:
|
|
476
|
+
# Fallback: ensure the flag is not left armed forever
|
|
477
|
+
self._force_scroll_lock_active = False
|
|
478
|
+
except Exception:
|
|
479
|
+
pass
|
|
480
|
+
|
|
95
481
|
@property
|
|
96
482
|
def _model(self):
|
|
97
483
|
return self.window.ui.models['ctx.list']
|
|
@@ -113,11 +499,124 @@ class ContextList(BaseList):
|
|
|
113
499
|
self._color_icon_cache[key] = icon
|
|
114
500
|
return icon
|
|
115
501
|
|
|
116
|
-
def
|
|
502
|
+
def _selected_rows(self):
|
|
117
503
|
"""
|
|
118
|
-
|
|
504
|
+
Returns selected row indexes (first column only).
|
|
505
|
+
"""
|
|
506
|
+
sel = self.selectionModel()
|
|
507
|
+
if not sel:
|
|
508
|
+
return []
|
|
509
|
+
try:
|
|
510
|
+
return [idx for idx in sel.selectedRows(0) if idx.isValid()]
|
|
511
|
+
except TypeError:
|
|
512
|
+
# Fallback if PySide6 binding lacks column overload
|
|
513
|
+
unique = set(i.row() for i in sel.selectedIndexes())
|
|
514
|
+
return [self._model.index(r, 0) for r in unique]
|
|
119
515
|
|
|
120
|
-
|
|
516
|
+
def _selected_item_ids(self) -> list:
|
|
517
|
+
"""
|
|
518
|
+
Returns IDs of selected non-group, non-section items.
|
|
519
|
+
"""
|
|
520
|
+
ids = []
|
|
521
|
+
for idx in self._selected_rows():
|
|
522
|
+
item = self._model.itemFromIndex(idx)
|
|
523
|
+
if isinstance(item, Item):
|
|
524
|
+
if hasattr(item, "id"):
|
|
525
|
+
ids.append(int(item.id))
|
|
526
|
+
return ids
|
|
527
|
+
|
|
528
|
+
def _selected_group_ids(self) -> list:
|
|
529
|
+
"""
|
|
530
|
+
Returns IDs of selected groups.
|
|
531
|
+
"""
|
|
532
|
+
ids = []
|
|
533
|
+
for idx in self._selected_rows():
|
|
534
|
+
item = self._model.itemFromIndex(idx)
|
|
535
|
+
if isinstance(item, GroupItem):
|
|
536
|
+
if hasattr(item, "id"):
|
|
537
|
+
ids.append(int(item.id))
|
|
538
|
+
return ids
|
|
539
|
+
|
|
540
|
+
def _selection_types(self) -> set:
|
|
541
|
+
"""
|
|
542
|
+
Returns a set describing current selection types: {'group'} | {'item'} | {'group','item'} | set()
|
|
543
|
+
"""
|
|
544
|
+
types = set()
|
|
545
|
+
for idx in self._selected_rows():
|
|
546
|
+
item = self._model.itemFromIndex(idx)
|
|
547
|
+
if isinstance(item, GroupItem):
|
|
548
|
+
types.add('group')
|
|
549
|
+
elif isinstance(item, Item):
|
|
550
|
+
types.add('item')
|
|
551
|
+
return types
|
|
552
|
+
|
|
553
|
+
def _has_multi_selection(self) -> bool:
|
|
554
|
+
"""
|
|
555
|
+
Returns True if more than one selectable (group or item) row is selected.
|
|
556
|
+
"""
|
|
557
|
+
count = 0
|
|
558
|
+
for idx in self._selected_rows():
|
|
559
|
+
it = self._model.itemFromIndex(idx)
|
|
560
|
+
if isinstance(it, (GroupItem, Item)):
|
|
561
|
+
count += 1
|
|
562
|
+
if count > 1:
|
|
563
|
+
return True
|
|
564
|
+
return False
|
|
565
|
+
|
|
566
|
+
def _is_group_index(self, index: QtCore.QModelIndex) -> bool:
|
|
567
|
+
"""
|
|
568
|
+
Returns True if the index points to a group/folder item.
|
|
569
|
+
"""
|
|
570
|
+
it = self._model.itemFromIndex(index)
|
|
571
|
+
return bool(isinstance(it, GroupItem))
|
|
572
|
+
|
|
573
|
+
def _can_toggle_with_ctrl(self, index: QtCore.QModelIndex) -> bool:
|
|
574
|
+
"""
|
|
575
|
+
Returns True if Ctrl-toggle on the given index would not mix selection types.
|
|
576
|
+
"""
|
|
577
|
+
if not index.isValid():
|
|
578
|
+
return False
|
|
579
|
+
target_is_group = self._is_group_index(index)
|
|
580
|
+
types = self._selection_types()
|
|
581
|
+
if not types:
|
|
582
|
+
return True
|
|
583
|
+
if types == {'group'} and target_is_group:
|
|
584
|
+
return True
|
|
585
|
+
if types == {'item'} and not target_is_group:
|
|
586
|
+
return True
|
|
587
|
+
return False
|
|
588
|
+
|
|
589
|
+
def _prune_selection_to_type(self, want_groups: bool):
|
|
590
|
+
"""
|
|
591
|
+
Deselects all rows that do not match desired type.
|
|
592
|
+
"""
|
|
593
|
+
sel = self.selectionModel()
|
|
594
|
+
if not sel:
|
|
595
|
+
return
|
|
596
|
+
for idx in self._selected_rows():
|
|
597
|
+
if self._is_group_index(idx) != want_groups:
|
|
598
|
+
sel.select(idx, QItemSelectionModel.Deselect | QItemSelectionModel.Rows)
|
|
599
|
+
|
|
600
|
+
def _on_selection_changed(self, selected, deselected):
|
|
601
|
+
"""
|
|
602
|
+
Keep selection homogeneous by removing indices of the opposite type.
|
|
603
|
+
"""
|
|
604
|
+
types = self._selection_types()
|
|
605
|
+
if len(types) <= 1:
|
|
606
|
+
return
|
|
607
|
+
# Decide desired type: prefer the last selection target if known, else majority
|
|
608
|
+
if self._last_selection_target_is_group is not None:
|
|
609
|
+
want_groups = bool(self._last_selection_target_is_group)
|
|
610
|
+
else:
|
|
611
|
+
# Majority fallback
|
|
612
|
+
g = len(self._selected_group_ids())
|
|
613
|
+
i = len(self._selected_item_ids())
|
|
614
|
+
want_groups = g >= i
|
|
615
|
+
self._prune_selection_to_type(want_groups)
|
|
616
|
+
|
|
617
|
+
def _perform_item_activation(self, index: QtCore.QModelIndex):
|
|
618
|
+
"""
|
|
619
|
+
Execute business action for a single click on the given index.
|
|
121
620
|
"""
|
|
122
621
|
item = self._model.itemFromIndex(index)
|
|
123
622
|
if item is None or not hasattr(item, 'isFolder'):
|
|
@@ -133,6 +632,34 @@ class ContextList(BaseList):
|
|
|
133
632
|
else:
|
|
134
633
|
self.window.controller.ctx.select_by_id(item.id)
|
|
135
634
|
|
|
635
|
+
def click(self, index):
|
|
636
|
+
"""
|
|
637
|
+
Click event (override, connected in BaseList class)
|
|
638
|
+
|
|
639
|
+
:param index: index
|
|
640
|
+
"""
|
|
641
|
+
# If we armed a "force-single" activation, bypass stale multi-state and suppression once
|
|
642
|
+
if self._force_single_click_index is not None:
|
|
643
|
+
try:
|
|
644
|
+
if index == self._force_single_click_index:
|
|
645
|
+
self._force_single_click_index = None
|
|
646
|
+
self._suppress_item_click = False
|
|
647
|
+
self._perform_item_activation(index)
|
|
648
|
+
return
|
|
649
|
+
finally:
|
|
650
|
+
# Always clear the one-shot guard
|
|
651
|
+
self._force_single_click_index = None
|
|
652
|
+
|
|
653
|
+
# Prevent side-effects (like open/toggle) during virtual multi-select or guarded clicks
|
|
654
|
+
if self._suppress_item_click:
|
|
655
|
+
self._suppress_item_click = False
|
|
656
|
+
return
|
|
657
|
+
# Ignore click side-effects if multiple rows (items or groups) are currently selected
|
|
658
|
+
if self._has_multi_selection():
|
|
659
|
+
return
|
|
660
|
+
|
|
661
|
+
self._perform_item_activation(index)
|
|
662
|
+
|
|
136
663
|
def expand_group(self, id):
|
|
137
664
|
"""
|
|
138
665
|
Expand group
|
|
@@ -155,34 +682,361 @@ class ContextList(BaseList):
|
|
|
155
682
|
"""
|
|
156
683
|
print("dblclick")
|
|
157
684
|
|
|
685
|
+
def _event_pos_to_point(self, event) -> QtCore.QPoint:
|
|
686
|
+
"""
|
|
687
|
+
Convert event position to QPoint, compatible with Qt6 and fallbacks.
|
|
688
|
+
"""
|
|
689
|
+
try:
|
|
690
|
+
return event.position().toPoint()
|
|
691
|
+
except Exception:
|
|
692
|
+
try:
|
|
693
|
+
return event.pos()
|
|
694
|
+
except Exception:
|
|
695
|
+
return QtCore.QPoint()
|
|
696
|
+
|
|
158
697
|
def mousePressEvent(self, event):
|
|
159
698
|
if event.button() == Qt.LeftButton:
|
|
160
|
-
|
|
699
|
+
pos = self._event_pos_to_point(event)
|
|
700
|
+
index = self.indexAt(pos)
|
|
701
|
+
no_mod = (event.modifiers() == Qt.NoModifier)
|
|
702
|
+
had_multi = self._has_multi_selection()
|
|
703
|
+
self._drag_press_pos = pos
|
|
704
|
+
self._drag_press_index = QPersistentModelIndex(index) if index.isValid() else None
|
|
705
|
+
self._drag_pending_from_multi = False
|
|
706
|
+
|
|
707
|
+
# Clear any stale suppression when user performs a plain left click
|
|
708
|
+
if no_mod:
|
|
709
|
+
self._suppress_item_click = False
|
|
710
|
+
|
|
711
|
+
# When multiple selection is active and a plain left click occurs:
|
|
712
|
+
# - clicking empty area clears the selection and consumes the click,
|
|
713
|
+
# - clicking a selected row preserves selection and arms drag start,
|
|
714
|
+
# - clicking an unselected row collapses multi-selection and arms one-shot activation.
|
|
715
|
+
if had_multi and no_mod:
|
|
716
|
+
sel = self.selectionModel()
|
|
717
|
+
if not index.isValid():
|
|
718
|
+
if sel:
|
|
719
|
+
sel.clearSelection()
|
|
720
|
+
self.setCurrentIndex(QtCore.QModelIndex())
|
|
721
|
+
try:
|
|
722
|
+
self.window.controller.ctx.unselect()
|
|
723
|
+
except Exception:
|
|
724
|
+
pass
|
|
725
|
+
self._force_single_click_index = None
|
|
726
|
+
event.accept()
|
|
727
|
+
return
|
|
728
|
+
|
|
729
|
+
if sel and sel.isSelected(index):
|
|
730
|
+
# Preserve current selection and arm manual drag for multi-select
|
|
731
|
+
self._drag_pending_from_multi = True
|
|
732
|
+
self._suppress_item_click = True
|
|
733
|
+
event.accept()
|
|
734
|
+
return
|
|
735
|
+
else:
|
|
736
|
+
# Collapse multi-selection and allow normal single-row behavior
|
|
737
|
+
if sel:
|
|
738
|
+
self._backup_selection = list(sel.selectedIndexes())
|
|
739
|
+
sel.clearSelection()
|
|
740
|
+
self.setCurrentIndex(QtCore.QModelIndex())
|
|
741
|
+
try:
|
|
742
|
+
self.window.controller.ctx.unselect()
|
|
743
|
+
except Exception:
|
|
744
|
+
pass
|
|
745
|
+
# Arm one-shot activation for the clicked row; Qt will select it afterwards
|
|
746
|
+
self._force_single_click_index = QPersistentModelIndex(index)
|
|
747
|
+
|
|
748
|
+
# Remember the target type for homogeneous selection control
|
|
749
|
+
if index.isValid():
|
|
750
|
+
self._last_selection_target_is_group = self._is_group_index(index)
|
|
751
|
+
else:
|
|
752
|
+
self._last_selection_target_is_group = None
|
|
753
|
+
|
|
754
|
+
# Ctrl-based virtual toggle: do not trigger "click" side effects; allow for groups and items
|
|
755
|
+
if event.modifiers() & Qt.ControlModifier:
|
|
756
|
+
if index.isValid():
|
|
757
|
+
if not self._can_toggle_with_ctrl(index):
|
|
758
|
+
# Normalize current selection to the target type so groups/items can be Ctrl-selected immediately
|
|
759
|
+
self._prune_selection_to_type(self._is_group_index(index))
|
|
760
|
+
sel = self.selectionModel()
|
|
761
|
+
if sel:
|
|
762
|
+
sel.select(index, QItemSelectionModel.Toggle | QItemSelectionModel.Rows)
|
|
763
|
+
self._suppress_item_click = True
|
|
764
|
+
self.viewport().update()
|
|
765
|
+
event.accept()
|
|
766
|
+
return
|
|
767
|
+
|
|
768
|
+
# Shift-based range select: allow default range behavior, but suppress side-effects and prune type
|
|
769
|
+
if event.modifiers() & Qt.ShiftModifier:
|
|
770
|
+
self._suppress_item_click = True
|
|
771
|
+
super().mousePressEvent(event)
|
|
772
|
+
# Prune to the anchor type to prevent mixed selection
|
|
773
|
+
if index.isValid():
|
|
774
|
+
self._prune_selection_to_type(self._is_group_index(index))
|
|
775
|
+
return
|
|
776
|
+
|
|
777
|
+
# Plain left click
|
|
161
778
|
if not index.isValid():
|
|
162
|
-
|
|
779
|
+
try:
|
|
780
|
+
self.window.controller.ctx.unselect()
|
|
781
|
+
except Exception:
|
|
782
|
+
pass
|
|
783
|
+
# Make sure next real click is not suppressed
|
|
784
|
+
self._suppress_item_click = False
|
|
785
|
+
event.accept()
|
|
163
786
|
return
|
|
787
|
+
|
|
164
788
|
super().mousePressEvent(event)
|
|
789
|
+
|
|
165
790
|
elif event.button() == Qt.RightButton:
|
|
166
791
|
index = self.indexAt(event.pos())
|
|
792
|
+
# Anchor scroll to the row under the RMB, regardless of current selection elsewhere
|
|
167
793
|
if index.isValid():
|
|
168
|
-
|
|
794
|
+
self._context_menu_anchor_index = QPersistentModelIndex(index)
|
|
795
|
+
try:
|
|
796
|
+
self._context_menu_anchor_scroll_value = self.verticalScrollBar().value()
|
|
797
|
+
except Exception:
|
|
798
|
+
self._context_menu_anchor_scroll_value = None
|
|
799
|
+
|
|
800
|
+
sel = self.selectionModel()
|
|
801
|
+
if not sel:
|
|
802
|
+
event.accept()
|
|
803
|
+
return
|
|
804
|
+
multi_items = len(self._selected_item_ids()) > 1
|
|
805
|
+
multi_groups = len(self._selected_group_ids()) > 1
|
|
806
|
+
# Keep current multi-selection if right-click happens on one of the selected rows
|
|
807
|
+
if index.isValid() and sel.isSelected(index) and (multi_items or multi_groups):
|
|
169
808
|
self._backup_selection = list(sel.selectedIndexes())
|
|
170
|
-
|
|
171
|
-
|
|
809
|
+
else:
|
|
810
|
+
# Default: right-click selects the row under cursor for single-row context actions
|
|
811
|
+
self._backup_selection = list(sel.selectedIndexes())
|
|
812
|
+
if index.isValid():
|
|
813
|
+
sel.clearSelection()
|
|
814
|
+
sel.select(index, QItemSelectionModel.Select | QItemSelectionModel.Rows)
|
|
172
815
|
event.accept()
|
|
173
816
|
else:
|
|
174
817
|
super().mousePressEvent(event)
|
|
175
818
|
|
|
819
|
+
def mouseMoveEvent(self, event):
|
|
820
|
+
"""
|
|
821
|
+
Manual drag start when multiple items are selected and user drags a selected row.
|
|
822
|
+
"""
|
|
823
|
+
try:
|
|
824
|
+
if (event.buttons() & Qt.LeftButton) and self._drag_pending_from_multi and not self._is_group_index(self._drag_press_index or QtCore.QModelIndex()):
|
|
825
|
+
pos = self._event_pos_to_point(event)
|
|
826
|
+
if (pos - self._drag_press_pos).manhattanLength() >= QtWidgets.QApplication.startDragDistance():
|
|
827
|
+
self._drag_pending_from_multi = False
|
|
828
|
+
self.startDrag(Qt.MoveAction)
|
|
829
|
+
event.accept()
|
|
830
|
+
return
|
|
831
|
+
except Exception:
|
|
832
|
+
# Fall back to default behavior on any error
|
|
833
|
+
self._drag_pending_from_multi = False
|
|
834
|
+
super().mouseMoveEvent(event)
|
|
835
|
+
|
|
836
|
+
def mouseReleaseEvent(self, event):
|
|
837
|
+
"""
|
|
838
|
+
Clean up drag state on release.
|
|
839
|
+
"""
|
|
840
|
+
if event.button() == Qt.LeftButton and self._drag_pending_from_multi:
|
|
841
|
+
self._drag_pending_from_multi = False
|
|
842
|
+
self._drag_press_index = None
|
|
843
|
+
super().mouseReleaseEvent(event)
|
|
844
|
+
|
|
845
|
+
def _build_multi_context_menu(self, ids: list[int]) -> QMenu:
|
|
846
|
+
"""
|
|
847
|
+
Build aggregated context menu for multiple selected items.
|
|
848
|
+
"""
|
|
849
|
+
menu = QMenu(self)
|
|
850
|
+
|
|
851
|
+
# Resolve contexts
|
|
852
|
+
ctx_list = []
|
|
853
|
+
for _id in ids:
|
|
854
|
+
meta = self.window.core.ctx.get_meta_by_id(_id)
|
|
855
|
+
if meta is not None:
|
|
856
|
+
ctx_list.append(meta)
|
|
857
|
+
|
|
858
|
+
# Determine mixed states
|
|
859
|
+
any_pinned = any(getattr(c, "important", False) for c in ctx_list)
|
|
860
|
+
any_unpinned = any(not getattr(c, "important", False) for c in ctx_list)
|
|
861
|
+
|
|
862
|
+
# Actions that pass a list of IDs
|
|
863
|
+
a_open = menu.addAction(self._icons['chat'], trans('action.open'))
|
|
864
|
+
a_open.triggered.connect(functools.partial(self.action_open, ids, None))
|
|
865
|
+
|
|
866
|
+
a_open_new_tab = menu.addAction(self._icons['chat'], trans('action.open_new_tab'))
|
|
867
|
+
a_open_new_tab.triggered.connect(functools.partial(self.action_open_new_tab, ids, None))
|
|
868
|
+
|
|
869
|
+
a_rename = menu.addAction(self._icons['edit'], trans('action.rename'))
|
|
870
|
+
a_rename.triggered.connect(functools.partial(self.action_rename, ids))
|
|
871
|
+
|
|
872
|
+
a_duplicate = menu.addAction(self._icons['copy'], trans('action.duplicate'))
|
|
873
|
+
a_duplicate.triggered.connect(functools.partial(self.action_duplicate, ids))
|
|
874
|
+
|
|
875
|
+
# Pin/Unpin: show both if state is mixed
|
|
876
|
+
if any_unpinned:
|
|
877
|
+
a_pin = menu.addAction(self._icons['pin'], trans('action.pin'))
|
|
878
|
+
a_pin.triggered.connect(functools.partial(self.action_pin, ids))
|
|
879
|
+
if any_pinned:
|
|
880
|
+
a_unpin = menu.addAction(self._icons['pin'], trans('action.unpin'))
|
|
881
|
+
a_unpin.triggered.connect(functools.partial(self.action_unpin, ids))
|
|
882
|
+
|
|
883
|
+
a_delete = menu.addAction(self._icons['delete'], trans('action.delete'))
|
|
884
|
+
a_delete.triggered.connect(functools.partial(self.action_delete, ids))
|
|
885
|
+
|
|
886
|
+
# Labels
|
|
887
|
+
colors = self.window.controller.ui.get_colors()
|
|
888
|
+
set_label_menu = menu.addMenu(trans('calendar.day.label'))
|
|
889
|
+
for status_id, status_info in colors.items():
|
|
890
|
+
name = trans('calendar.day.' + status_info['label']) if status_id != 0 else '-'
|
|
891
|
+
icon = self._color_icon(status_info['color'])
|
|
892
|
+
status_action = set_label_menu.addAction(icon, name)
|
|
893
|
+
status_action.triggered.connect(
|
|
894
|
+
functools.partial(self.action_set_label, ids, status_id)
|
|
895
|
+
)
|
|
896
|
+
|
|
897
|
+
# Indexing (IDX) aggregated
|
|
898
|
+
idx_menu = QMenu(trans('action.idx'), self)
|
|
899
|
+
idxs = self.window.core.config.get('llama.idx.list')
|
|
900
|
+
store = self.window.core.idx.get_current_store()
|
|
901
|
+
|
|
902
|
+
# Provide all available "index to" targets
|
|
903
|
+
if idxs:
|
|
904
|
+
for idx_dict in idxs:
|
|
905
|
+
index_id = idx_dict['id']
|
|
906
|
+
name = idx_dict['name'] + " (" + idx_dict['id'] + ")"
|
|
907
|
+
action = idx_menu.addAction(self._icons['db'], "IDX: " + name)
|
|
908
|
+
action.triggered.connect(functools.partial(self.action_idx, ids, index_id))
|
|
909
|
+
|
|
910
|
+
# Provide "remove from" for the union of indexes over the current store
|
|
911
|
+
union_store_indexes = set()
|
|
912
|
+
for c in ctx_list:
|
|
913
|
+
if getattr(c, "indexed", None) and getattr(c, "indexes", None):
|
|
914
|
+
if store in c.indexes:
|
|
915
|
+
for sidx in c.indexes[store]:
|
|
916
|
+
union_store_indexes.add(sidx)
|
|
917
|
+
if union_store_indexes:
|
|
918
|
+
idx_menu.addSeparator()
|
|
919
|
+
for store_index in sorted(union_store_indexes):
|
|
920
|
+
action = idx_menu.addAction(self._icons['delete'], trans("action.idx.remove") + ": " + store_index)
|
|
921
|
+
action.triggered.connect(
|
|
922
|
+
functools.partial(self.action_idx_remove, store_index, ids)
|
|
923
|
+
)
|
|
924
|
+
menu.addMenu(idx_menu)
|
|
925
|
+
|
|
926
|
+
# Group operations
|
|
927
|
+
group_menu = QMenu(trans('action.move_to'), self)
|
|
928
|
+
groups = self.window.core.ctx.get_groups()
|
|
929
|
+
|
|
930
|
+
action = group_menu.addAction(self._icons['add'], trans("action.group.new"))
|
|
931
|
+
action.triggered.connect(functools.partial(self.window.controller.ctx.new_group, ids))
|
|
932
|
+
|
|
933
|
+
if groups:
|
|
934
|
+
group_menu.addSeparator()
|
|
935
|
+
|
|
936
|
+
for group_id, group in groups.items():
|
|
937
|
+
action = group_menu.addAction(self._icons['folder'], group.name)
|
|
938
|
+
action.triggered.connect(functools.partial(self.window.controller.ctx.move_to_group, ids, group_id))
|
|
939
|
+
|
|
940
|
+
# Remove from group if any selected is in a group
|
|
941
|
+
in_any_group = any(getattr(c, "group_id", None) not in (None, 0) for c in ctx_list)
|
|
942
|
+
if groups or in_any_group:
|
|
943
|
+
group_menu.addSeparator()
|
|
944
|
+
if in_any_group:
|
|
945
|
+
action = group_menu.addAction(self._icons['delete'], trans("action.group.remove"))
|
|
946
|
+
action.triggered.connect(functools.partial(self.window.controller.ctx.remove_from_group, ids))
|
|
947
|
+
|
|
948
|
+
menu.addMenu(group_menu)
|
|
949
|
+
|
|
950
|
+
# Copy IDs (list)
|
|
951
|
+
a_copy_ids = menu.addAction(self._icons['copy'], trans('action.ctx_copy_id') + " x" + str(len(ids)))
|
|
952
|
+
a_copy_ids.triggered.connect(functools.partial(self.action_copy_id, ids))
|
|
953
|
+
|
|
954
|
+
# Reset (list)
|
|
955
|
+
a_reset = menu.addAction(self._icons['close'], trans('action.ctx_reset'))
|
|
956
|
+
a_reset.triggered.connect(functools.partial(self.action_reset, ids))
|
|
957
|
+
|
|
958
|
+
return menu
|
|
959
|
+
|
|
960
|
+
def _build_multi_group_context_menu(self, group_ids: list[int]) -> QMenu:
|
|
961
|
+
"""
|
|
962
|
+
Build aggregated context menu for multiple selected groups.
|
|
963
|
+
"""
|
|
964
|
+
menu = QMenu(self)
|
|
965
|
+
|
|
966
|
+
a_new = menu.addAction(self._icons['add'], trans('action.ctx.new'))
|
|
967
|
+
a_new.triggered.connect(functools.partial(self.action_group_new_in_group, group_ids))
|
|
968
|
+
|
|
969
|
+
a_rename = menu.addAction(self._icons['edit'], trans('action.rename'))
|
|
970
|
+
a_rename.triggered.connect(functools.partial(self.action_group_rename, group_ids))
|
|
971
|
+
|
|
972
|
+
a_delete = menu.addAction(self._icons['delete'], trans('action.group.delete.only'))
|
|
973
|
+
a_delete.triggered.connect(functools.partial(self.action_group_delete_only, group_ids))
|
|
974
|
+
|
|
975
|
+
a_delete_all = menu.addAction(self._icons['delete'], trans('action.group.delete.all'))
|
|
976
|
+
a_delete_all.triggered.connect(functools.partial(self.action_group_delete_all, group_ids))
|
|
977
|
+
|
|
978
|
+
# Copy group IDs (list)
|
|
979
|
+
a_copy = menu.addAction(self._icons['copy'], trans('action.ctx_copy_id') + " x" + str(len(group_ids)))
|
|
980
|
+
a_copy.triggered.connect(functools.partial(self.action_copy_id, group_ids))
|
|
981
|
+
|
|
982
|
+
return menu
|
|
983
|
+
|
|
176
984
|
def show_context_menu(self, pos: QPoint):
|
|
177
985
|
"""
|
|
178
986
|
Context menu event
|
|
179
987
|
|
|
180
988
|
:param pos: QPoint
|
|
181
989
|
"""
|
|
182
|
-
|
|
990
|
+
# Capture RMB anchor for scroll: item under cursor + current scroll value
|
|
183
991
|
index = self.indexAt(pos)
|
|
992
|
+
if index.isValid():
|
|
993
|
+
self._context_menu_anchor_index = QPersistentModelIndex(index)
|
|
994
|
+
else:
|
|
995
|
+
self._context_menu_anchor_index = None
|
|
996
|
+
try:
|
|
997
|
+
self._context_menu_anchor_scroll_value = self.verticalScrollBar().value()
|
|
998
|
+
except Exception:
|
|
999
|
+
self._context_menu_anchor_scroll_value = None
|
|
1000
|
+
|
|
1001
|
+
global_pos = self.viewport().mapToGlobal(pos)
|
|
184
1002
|
item = self._model.itemFromIndex(index)
|
|
185
1003
|
|
|
1004
|
+
# If multiple groups are selected and the click was on a selected group row, show aggregated group menu
|
|
1005
|
+
selected_group_ids = self._selected_group_ids()
|
|
1006
|
+
if len(selected_group_ids) > 1 and index.isValid() and self.selectionModel().isSelected(index) and self._is_group_index(index):
|
|
1007
|
+
menu = self._build_multi_group_context_menu(selected_group_ids)
|
|
1008
|
+
if menu:
|
|
1009
|
+
menu.exec(global_pos)
|
|
1010
|
+
|
|
1011
|
+
self.store_scroll_position()
|
|
1012
|
+
if self.restore_after_ctx_menu and self._backup_selection is not None:
|
|
1013
|
+
sel = self.selectionModel()
|
|
1014
|
+
sel.clearSelection()
|
|
1015
|
+
for sel_idx in self._backup_selection:
|
|
1016
|
+
sel.select(sel_idx, QItemSelectionModel.Select | QItemSelectionModel.Rows)
|
|
1017
|
+
self._backup_selection = None
|
|
1018
|
+
self.restore_after_ctx_menu = True
|
|
1019
|
+
self.restore_scroll_position()
|
|
1020
|
+
return
|
|
1021
|
+
|
|
1022
|
+
# If multiple items are selected and the click was on a selected item row, show aggregated menu
|
|
1023
|
+
selected_ids = self._selected_item_ids()
|
|
1024
|
+
if len(selected_ids) > 1 and index.isValid() and self.selectionModel().isSelected(index) and not self._is_group_index(index):
|
|
1025
|
+
menu = self._build_multi_context_menu(selected_ids)
|
|
1026
|
+
if menu:
|
|
1027
|
+
menu.exec(global_pos)
|
|
1028
|
+
|
|
1029
|
+
self.store_scroll_position()
|
|
1030
|
+
if self.restore_after_ctx_menu and self._backup_selection is not None:
|
|
1031
|
+
sel = self.selectionModel()
|
|
1032
|
+
sel.clearSelection()
|
|
1033
|
+
for sel_idx in self._backup_selection:
|
|
1034
|
+
sel.select(sel_idx, QItemSelectionModel.Select | QItemSelectionModel.Rows)
|
|
1035
|
+
self._backup_selection = None
|
|
1036
|
+
self.restore_after_ctx_menu = True
|
|
1037
|
+
self.restore_scroll_position()
|
|
1038
|
+
return
|
|
1039
|
+
|
|
186
1040
|
if item is not None and index.isValid() and hasattr(item, 'id'):
|
|
187
1041
|
idx = item.row()
|
|
188
1042
|
id_value = item.id
|
|
@@ -207,6 +1061,8 @@ class ContextList(BaseList):
|
|
|
207
1061
|
|
|
208
1062
|
is_important = ctx.important
|
|
209
1063
|
|
|
1064
|
+
# For single selection payloads, pass a single ID
|
|
1065
|
+
|
|
210
1066
|
menu = QMenu(self)
|
|
211
1067
|
a_open = menu.addAction(self._icons['chat'], trans('action.open'))
|
|
212
1068
|
a_open.triggered.connect(functools.partial(self.action_open, ctx_id, idx))
|
|
@@ -301,6 +1157,7 @@ class ContextList(BaseList):
|
|
|
301
1157
|
a_reset.triggered.connect(functools.partial(self.action_reset, ctx_id))
|
|
302
1158
|
|
|
303
1159
|
if idx >= 0:
|
|
1160
|
+
# Keep internal single selection marker unchanged
|
|
304
1161
|
self.window.controller.ctx.set_selected(ctx_id)
|
|
305
1162
|
menu.exec(global_pos)
|
|
306
1163
|
|
|
@@ -336,133 +1193,402 @@ class ContextList(BaseList):
|
|
|
336
1193
|
ids.add(int(it.id))
|
|
337
1194
|
return ids
|
|
338
1195
|
|
|
339
|
-
def action_open(self, id
|
|
1196
|
+
def action_open(self, id, idx: Union[int, list] = None):
|
|
340
1197
|
"""
|
|
341
|
-
Open context action handler
|
|
1198
|
+
Open context action handler.
|
|
1199
|
+
Accepts either a single string ID or a list of integer IDs.
|
|
342
1200
|
|
|
343
|
-
:param id: context id
|
|
1201
|
+
:param id: context id (str) or list of ids (list[int])
|
|
344
1202
|
:param idx: index id (optional)
|
|
345
1203
|
"""
|
|
346
1204
|
self.restore_after_ctx_menu = False
|
|
1205
|
+
if isinstance(id, list) and len(id) > 0:
|
|
1206
|
+
# use the first selected item's index for multiple selection
|
|
1207
|
+
id = id[0]
|
|
347
1208
|
self.window.controller.ctx.load(id, select_idx=idx)
|
|
348
1209
|
|
|
349
|
-
def action_open_new_tab(self, id
|
|
1210
|
+
def action_open_new_tab(self, id, idx: int = None):
|
|
350
1211
|
"""
|
|
351
|
-
Open context action handler in
|
|
1212
|
+
Open context action handler in a new tab.
|
|
1213
|
+
Accepts either a single string ID or a list of integer IDs.
|
|
352
1214
|
|
|
353
|
-
:param id: context id
|
|
1215
|
+
:param id: context id (str) or list of ids (list[int])
|
|
354
1216
|
:param idx: index id (optional)
|
|
355
1217
|
"""
|
|
356
1218
|
self.restore_after_ctx_menu = False
|
|
1219
|
+
if isinstance(id, list):
|
|
1220
|
+
for i in id:
|
|
1221
|
+
self.window.controller.ctx.load(i, new_tab=True)
|
|
1222
|
+
return
|
|
357
1223
|
self.window.controller.ctx.load(id, select_idx=idx, new_tab=True)
|
|
358
1224
|
|
|
359
|
-
def action_idx(self, id
|
|
1225
|
+
def action_idx(self, id, idx):
|
|
360
1226
|
"""
|
|
361
|
-
Index with llama context action handler
|
|
1227
|
+
Index with llama context action handler.
|
|
1228
|
+
Accepts either a single string ID or a list of integer IDs.
|
|
362
1229
|
|
|
363
|
-
:param id: context id
|
|
364
|
-
:param idx: index name
|
|
1230
|
+
:param id: context id (str) or list of ids (list[int])
|
|
1231
|
+
:param idx: index name/id
|
|
365
1232
|
"""
|
|
366
1233
|
self.restore_after_ctx_menu = False
|
|
367
1234
|
self.window.controller.idx.indexer.index_ctx_meta(id, idx)
|
|
368
1235
|
|
|
369
|
-
def action_idx_remove(self, idx: str, meta_id
|
|
1236
|
+
def action_idx_remove(self, idx: str, meta_id):
|
|
370
1237
|
"""
|
|
371
|
-
Remove from index action handler
|
|
1238
|
+
Remove from index action handler.
|
|
1239
|
+
Accepts either a single string ID or a list of integer IDs.
|
|
372
1240
|
|
|
373
1241
|
:param idx: index id
|
|
374
|
-
:param meta_id: meta id
|
|
1242
|
+
:param meta_id: meta id (str) or list of ids (list[int])
|
|
375
1243
|
"""
|
|
376
1244
|
self.restore_after_ctx_menu = False
|
|
377
1245
|
self.window.controller.idx.indexer.index_ctx_meta_remove(idx, meta_id)
|
|
378
1246
|
|
|
379
1247
|
def action_rename(self, id):
|
|
380
1248
|
"""
|
|
381
|
-
Rename action handler
|
|
1249
|
+
Rename action handler.
|
|
1250
|
+
Accepts either a single string ID or a list of integer IDs.
|
|
382
1251
|
|
|
383
|
-
:param id: context id
|
|
1252
|
+
:param id: context id or list of ids
|
|
384
1253
|
"""
|
|
385
1254
|
self.restore_after_ctx_menu = False
|
|
386
1255
|
self.window.controller.ctx.rename(id)
|
|
387
1256
|
|
|
388
1257
|
def action_pin(self, id):
|
|
389
1258
|
"""
|
|
390
|
-
Pin action handler
|
|
1259
|
+
Pin action handler.
|
|
1260
|
+
Accepts either a single string ID or a list of integer IDs.
|
|
391
1261
|
|
|
392
|
-
:param id: context id
|
|
1262
|
+
:param id: context id or list of ids
|
|
393
1263
|
"""
|
|
394
1264
|
self.restore_after_ctx_menu = False
|
|
395
1265
|
self.window.controller.ctx.set_important(id, True)
|
|
396
1266
|
|
|
397
1267
|
def action_unpin(self, id):
|
|
398
1268
|
"""
|
|
399
|
-
Unpin action handler
|
|
1269
|
+
Unpin action handler.
|
|
1270
|
+
Accepts either a single string ID or a list of integer IDs.
|
|
400
1271
|
|
|
401
|
-
:param id: context id
|
|
1272
|
+
:param id: context id or list of ids
|
|
402
1273
|
"""
|
|
403
1274
|
self.restore_after_ctx_menu = False
|
|
404
1275
|
self.window.controller.ctx.set_important(id, False)
|
|
405
1276
|
|
|
406
1277
|
def action_important(self, id):
|
|
407
1278
|
"""
|
|
408
|
-
Set as important action handler
|
|
1279
|
+
Set as important action handler.
|
|
1280
|
+
Accepts either a single string ID or a list of integer IDs.
|
|
409
1281
|
|
|
410
|
-
:param id: context id
|
|
1282
|
+
:param id: context id or list of ids
|
|
411
1283
|
"""
|
|
412
1284
|
self.restore_after_ctx_menu = False
|
|
413
1285
|
self.window.controller.ctx.set_important(id)
|
|
414
1286
|
|
|
415
1287
|
def action_duplicate(self, id):
|
|
416
1288
|
"""
|
|
417
|
-
Duplicate handler
|
|
1289
|
+
Duplicate handler.
|
|
1290
|
+
Accepts either a single string ID or a list of integer IDs.
|
|
418
1291
|
|
|
419
|
-
:param id: context id
|
|
1292
|
+
:param id: context id or list of ids
|
|
420
1293
|
"""
|
|
421
1294
|
self.window.controller.ctx.common.duplicate(id)
|
|
422
1295
|
|
|
423
1296
|
def action_delete(self, id):
|
|
424
1297
|
"""
|
|
425
|
-
Delete action handler
|
|
1298
|
+
Delete action handler.
|
|
1299
|
+
Accepts either a single string ID or a list of integer IDs.
|
|
426
1300
|
|
|
427
|
-
:param id: context id
|
|
1301
|
+
:param id: context id or list of ids
|
|
428
1302
|
"""
|
|
1303
|
+
# Anchor scroll to RMB-targeted viewport position if available, else keep current value
|
|
1304
|
+
anchor_val = self._context_menu_anchor_scroll_value
|
|
1305
|
+
self._deletion_initiated = True
|
|
1306
|
+
self._activate_scroll_guard("delete", anchor_val)
|
|
429
1307
|
self.restore_after_ctx_menu = False
|
|
430
1308
|
self.window.controller.ctx.delete(id)
|
|
431
1309
|
|
|
432
1310
|
def action_copy_id(self, id):
|
|
433
1311
|
"""
|
|
434
|
-
Copy ID
|
|
1312
|
+
Copy ID(s) action handler.
|
|
1313
|
+
Accepts either a single string ID or a list of integer IDs.
|
|
435
1314
|
|
|
436
|
-
:param id: context id
|
|
1315
|
+
:param id: context id or list of ids
|
|
437
1316
|
"""
|
|
438
1317
|
self.window.controller.ctx.common.copy_id(id)
|
|
439
1318
|
|
|
440
1319
|
def action_reset(self, id):
|
|
441
1320
|
"""
|
|
442
|
-
Reset action handler
|
|
1321
|
+
Reset action handler.
|
|
1322
|
+
Accepts either a single string ID or a list of integer IDs.
|
|
443
1323
|
|
|
444
|
-
:param id: context id
|
|
1324
|
+
:param id: context id or list of ids
|
|
445
1325
|
"""
|
|
446
1326
|
self.restore_after_ctx_menu = False
|
|
447
1327
|
self.window.controller.ctx.common.reset(id)
|
|
448
1328
|
|
|
449
|
-
def action_set_label(self, id
|
|
1329
|
+
def action_set_label(self, id, label: int):
|
|
450
1330
|
"""
|
|
451
|
-
Set label action handler
|
|
1331
|
+
Set label action handler.
|
|
1332
|
+
Accepts either a single string ID or a list of integer IDs.
|
|
452
1333
|
|
|
453
|
-
:param id: context id
|
|
1334
|
+
:param id: context id or list of ids
|
|
454
1335
|
:param label: label id
|
|
455
1336
|
"""
|
|
456
1337
|
self.window.controller.ctx.set_label(id, label)
|
|
457
1338
|
|
|
1339
|
+
# Group bulk/single wrappers (accept single id or list of ids)
|
|
1340
|
+
def action_group_new_in_group(self, group_id_or_ids):
|
|
1341
|
+
"""
|
|
1342
|
+
Create new context(s) inside the given group(s).
|
|
1343
|
+
"""
|
|
1344
|
+
self.restore_after_ctx_menu = False
|
|
1345
|
+
self.window.controller.ctx.new_in_group(force=False, group_id=group_id_or_ids)
|
|
1346
|
+
|
|
1347
|
+
def action_group_rename(self, group_id_or_ids):
|
|
1348
|
+
"""
|
|
1349
|
+
Rename group(s).
|
|
1350
|
+
"""
|
|
1351
|
+
self.restore_after_ctx_menu = False
|
|
1352
|
+
self.window.controller.ctx.rename_group(group_id_or_ids)
|
|
1353
|
+
|
|
1354
|
+
def action_group_delete_only(self, group_id_or_ids):
|
|
1355
|
+
"""
|
|
1356
|
+
Delete group(s) only (keep items).
|
|
1357
|
+
"""
|
|
1358
|
+
# Preserve scroll around group deletion as well to avoid jump; anchor to RMB target if present
|
|
1359
|
+
anchor_val = self._context_menu_anchor_scroll_value
|
|
1360
|
+
self._deletion_initiated = True
|
|
1361
|
+
self._activate_scroll_guard("group_delete_only", anchor_val)
|
|
1362
|
+
self.restore_after_ctx_menu = False
|
|
1363
|
+
self.window.controller.ctx.delete_group(group_id_or_ids)
|
|
1364
|
+
|
|
1365
|
+
def action_group_delete_all(self, group_id_or_ids):
|
|
1366
|
+
"""
|
|
1367
|
+
Delete group(s) with all items.
|
|
1368
|
+
"""
|
|
1369
|
+
# Preserve scroll around group deletion as well to avoid jumps; anchor to RMB target if present
|
|
1370
|
+
anchor_val = self._context_menu_anchor_scroll_value
|
|
1371
|
+
self._deletion_initiated = True
|
|
1372
|
+
self._activate_scroll_guard("group_delete_all", anchor_val)
|
|
1373
|
+
self.restore_after_ctx_menu = False
|
|
1374
|
+
self.window.controller.ctx.delete_group_all(group_id_or_ids)
|
|
1375
|
+
|
|
458
1376
|
def selectionCommand(self, index, event=None):
|
|
459
1377
|
"""
|
|
460
1378
|
Selection command
|
|
461
1379
|
:param index: Index
|
|
462
1380
|
:param event: Event
|
|
463
1381
|
"""
|
|
1382
|
+
# Prevent mixing selection types (groups vs items)
|
|
1383
|
+
try:
|
|
1384
|
+
if index and index.isValid():
|
|
1385
|
+
target_is_group = self._is_group_index(index)
|
|
1386
|
+
types = self._selection_types()
|
|
1387
|
+
if types == {'group'} and not target_is_group:
|
|
1388
|
+
return QItemSelectionModel.NoUpdate
|
|
1389
|
+
if types == {'item'} and target_is_group:
|
|
1390
|
+
return QItemSelectionModel.NoUpdate
|
|
1391
|
+
except Exception:
|
|
1392
|
+
pass
|
|
464
1393
|
return super().selectionCommand(index, event)
|
|
465
1394
|
|
|
1395
|
+
# =========================
|
|
1396
|
+
# Drag & Drop implementation
|
|
1397
|
+
# =========================
|
|
1398
|
+
|
|
1399
|
+
def _is_valid_drag_source_selection(self) -> bool:
|
|
1400
|
+
"""
|
|
1401
|
+
Returns True if current selection contains only non-group items.
|
|
1402
|
+
"""
|
|
1403
|
+
types = self._selection_types()
|
|
1404
|
+
return types in (set(), {'item'}) and len(self._selected_item_ids()) > 0
|
|
1405
|
+
|
|
1406
|
+
def startDrag(self, supportedActions):
|
|
1407
|
+
"""
|
|
1408
|
+
Start drag only for non-group items. Pack selected item IDs into custom mime.
|
|
1409
|
+
"""
|
|
1410
|
+
if not self._is_valid_drag_source_selection():
|
|
1411
|
+
return # do not start drag for groups or empty selection
|
|
1412
|
+
|
|
1413
|
+
ids = self._selected_item_ids()
|
|
1414
|
+
if not ids:
|
|
1415
|
+
return
|
|
1416
|
+
|
|
1417
|
+
mime = QtCore.QMimeData()
|
|
1418
|
+
payload = ",".join(str(i) for i in ids).encode("utf-8")
|
|
1419
|
+
mime.setData(self._drag_mime, payload)
|
|
1420
|
+
mime.setText(",".join(str(i) for i in ids))
|
|
1421
|
+
|
|
1422
|
+
drag = QDrag(self)
|
|
1423
|
+
drag.setMimeData(mime)
|
|
1424
|
+
|
|
1425
|
+
# Compact drag pixmap with count
|
|
1426
|
+
w, h = 140, 28
|
|
1427
|
+
pm = QPixmap(w, h)
|
|
1428
|
+
pm.fill(Qt.transparent)
|
|
1429
|
+
painter = QtGui.QPainter(pm)
|
|
1430
|
+
painter.setRenderHint(QtGui.QPainter.Antialiasing, True)
|
|
1431
|
+
rect = QtCore.QRectF(0.5, 0.5, w - 1, h - 1)
|
|
1432
|
+
bg = QColor(40, 120, 255, 32)
|
|
1433
|
+
pen = QtGui.QPen(QColor(40, 120, 255, 200), 1.5)
|
|
1434
|
+
painter.setPen(pen)
|
|
1435
|
+
painter.setBrush(bg)
|
|
1436
|
+
painter.drawRoundedRect(rect, 6, 6)
|
|
1437
|
+
painter.setPen(QColor(40, 120, 255, 230))
|
|
1438
|
+
text = "Move {} item{}".format(len(ids), "" if len(ids) == 1 else "s")
|
|
1439
|
+
painter.drawText(pm.rect(), Qt.AlignCenter, text)
|
|
1440
|
+
painter.end()
|
|
1441
|
+
drag.setPixmap(pm)
|
|
1442
|
+
drag.setHotSpot(QtCore.QPoint(pm.width() // 2, pm.height() // 2))
|
|
1443
|
+
|
|
1444
|
+
drag.exec(Qt.MoveAction)
|
|
1445
|
+
|
|
1446
|
+
def dragEnterEvent(self, event: QtGui.QDragEnterEvent):
|
|
1447
|
+
"""
|
|
1448
|
+
Accept drags that carry our custom payload (item IDs).
|
|
1449
|
+
"""
|
|
1450
|
+
md = event.mimeData()
|
|
1451
|
+
if md and md.hasFormat(self._drag_mime):
|
|
1452
|
+
event.acceptProposedAction()
|
|
1453
|
+
return
|
|
1454
|
+
event.ignore()
|
|
1455
|
+
|
|
1456
|
+
def dragMoveEvent(self, event: QtGui.QDragMoveEvent):
|
|
1457
|
+
"""
|
|
1458
|
+
While dragging:
|
|
1459
|
+
- accept only when hovering over a group row,
|
|
1460
|
+
- show highlight frame over the target group row.
|
|
1461
|
+
"""
|
|
1462
|
+
md = event.mimeData()
|
|
1463
|
+
if not md or not md.hasFormat(self._drag_mime):
|
|
1464
|
+
self._hide_drop_highlight()
|
|
1465
|
+
event.ignore()
|
|
1466
|
+
return
|
|
1467
|
+
|
|
1468
|
+
pos = self._event_pos_to_point(event)
|
|
1469
|
+
index = self.indexAt(pos)
|
|
1470
|
+
|
|
1471
|
+
if index.isValid() and self._is_group_index(index):
|
|
1472
|
+
self._update_drop_highlight(index)
|
|
1473
|
+
event.setDropAction(Qt.MoveAction)
|
|
1474
|
+
event.accept()
|
|
1475
|
+
else:
|
|
1476
|
+
self._hide_drop_highlight()
|
|
1477
|
+
event.ignore()
|
|
1478
|
+
|
|
1479
|
+
def dragLeaveEvent(self, event: QtGui.QDragLeaveEvent):
|
|
1480
|
+
"""
|
|
1481
|
+
Hide highlight when drag leaves the view.
|
|
1482
|
+
"""
|
|
1483
|
+
self._hide_drop_highlight()
|
|
1484
|
+
event.accept()
|
|
1485
|
+
|
|
1486
|
+
def dropEvent(self, event: QtGui.QDropEvent):
|
|
1487
|
+
"""
|
|
1488
|
+
On drop:
|
|
1489
|
+
- parse dragged item IDs,
|
|
1490
|
+
- resolve group row under cursor,
|
|
1491
|
+
- call controller.move_to_group(ids, group_id).
|
|
1492
|
+
"""
|
|
1493
|
+
try:
|
|
1494
|
+
md = event.mimeData()
|
|
1495
|
+
if not md or not md.hasFormat(self._drag_mime):
|
|
1496
|
+
self._hide_drop_highlight()
|
|
1497
|
+
event.ignore()
|
|
1498
|
+
return
|
|
1499
|
+
|
|
1500
|
+
ids = self._parse_drag_ids(md)
|
|
1501
|
+
if not ids:
|
|
1502
|
+
self._hide_drop_highlight()
|
|
1503
|
+
event.ignore()
|
|
1504
|
+
return
|
|
1505
|
+
|
|
1506
|
+
pos = self._event_pos_to_point(event)
|
|
1507
|
+
index = self.indexAt(pos)
|
|
1508
|
+
if not index.isValid() or not self._is_group_index(index):
|
|
1509
|
+
self._hide_drop_highlight()
|
|
1510
|
+
event.ignore()
|
|
1511
|
+
return
|
|
1512
|
+
|
|
1513
|
+
group_item = self._model.itemFromIndex(index)
|
|
1514
|
+
group_id = int(getattr(group_item, "id", 0))
|
|
1515
|
+
|
|
1516
|
+
# Preserve scroll around move operations to avoid jumps
|
|
1517
|
+
try:
|
|
1518
|
+
anchor_val = self.verticalScrollBar().value()
|
|
1519
|
+
except Exception:
|
|
1520
|
+
anchor_val = None
|
|
1521
|
+
self._activate_scroll_guard("dragdrop_move", anchor_val)
|
|
1522
|
+
|
|
1523
|
+
# Perform move via controller (accepts single ID or list)
|
|
1524
|
+
self.window.controller.ctx.move_to_group(ids, group_id)
|
|
1525
|
+
|
|
1526
|
+
self._hide_drop_highlight()
|
|
1527
|
+
event.setDropAction(Qt.MoveAction)
|
|
1528
|
+
event.accept()
|
|
1529
|
+
# schedule restore (layout changes should trigger, but ensure anyway)
|
|
1530
|
+
self._schedule_scroll_restore()
|
|
1531
|
+
except Exception:
|
|
1532
|
+
# Fail-safe: do not break DnD if something goes wrong
|
|
1533
|
+
self._hide_drop_highlight()
|
|
1534
|
+
event.ignore()
|
|
1535
|
+
|
|
1536
|
+
def _parse_drag_ids(self, mime: QtCore.QMimeData) -> list[int]:
|
|
1537
|
+
"""
|
|
1538
|
+
Decode list of dragged item IDs from mime data.
|
|
1539
|
+
"""
|
|
1540
|
+
try:
|
|
1541
|
+
raw = bytes(mime.data(self._drag_mime)).decode("utf-8").strip()
|
|
1542
|
+
if not raw:
|
|
1543
|
+
return []
|
|
1544
|
+
out = []
|
|
1545
|
+
for part in raw.split(","):
|
|
1546
|
+
part = strip = part.strip()
|
|
1547
|
+
if not strip:
|
|
1548
|
+
continue
|
|
1549
|
+
try:
|
|
1550
|
+
out.append(int(strip))
|
|
1551
|
+
except Exception:
|
|
1552
|
+
continue
|
|
1553
|
+
return out
|
|
1554
|
+
except Exception:
|
|
1555
|
+
return []
|
|
1556
|
+
|
|
1557
|
+
def _update_drop_highlight(self, index: QtCore.QModelIndex):
|
|
1558
|
+
"""
|
|
1559
|
+
Show and position the highlight frame around the given group row.
|
|
1560
|
+
"""
|
|
1561
|
+
try:
|
|
1562
|
+
if not index.isValid() or not self._is_group_index(index):
|
|
1563
|
+
self._hide_drop_highlight()
|
|
1564
|
+
return
|
|
1565
|
+
|
|
1566
|
+
rect = self.visualRect(index)
|
|
1567
|
+
if not rect.isValid() or rect.width() <= 0 or rect.height() <= 0:
|
|
1568
|
+
self._hide_drop_highlight()
|
|
1569
|
+
return
|
|
1570
|
+
|
|
1571
|
+
self._drop_highlight_index = QPersistentModelIndex(index)
|
|
1572
|
+
# Slightly inflate the rect for a nicer look without clipping
|
|
1573
|
+
geom = rect.adjusted(1, 1, -1, -1)
|
|
1574
|
+
self._dir_highlight.setGeometry(geom)
|
|
1575
|
+
self._dir_highlight.raise_()
|
|
1576
|
+
if not self._dir_highlight.isVisible():
|
|
1577
|
+
self._dir_highlight.show()
|
|
1578
|
+
except Exception:
|
|
1579
|
+
self._hide_drop_highlight()
|
|
1580
|
+
|
|
1581
|
+
def _hide_drop_highlight(self):
|
|
1582
|
+
"""
|
|
1583
|
+
Hide the highlight frame and clear state.
|
|
1584
|
+
"""
|
|
1585
|
+
try:
|
|
1586
|
+
if self._dir_highlight.isVisible():
|
|
1587
|
+
self._dir_highlight.hide()
|
|
1588
|
+
except Exception:
|
|
1589
|
+
pass
|
|
1590
|
+
self._drop_highlight_index = None
|
|
1591
|
+
|
|
466
1592
|
|
|
467
1593
|
class ImportantItemDelegate(QtWidgets.QStyledItemDelegate):
|
|
468
1594
|
"""
|
|
@@ -494,11 +1620,11 @@ class ImportantItemDelegate(QtWidgets.QStyledItemDelegate):
|
|
|
494
1620
|
|
|
495
1621
|
# Visual tuning constants
|
|
496
1622
|
self._pin_pen = QtGui.QPen(QtCore.Qt.black, 0.5, QtCore.Qt.SolidLine) # kept for compatibility
|
|
497
|
-
self._pin_diameter = 4
|
|
498
|
-
self._pin_margin = 3
|
|
499
|
-
self._attach_spacing = 4
|
|
500
|
-
self._label_bar_width = 4
|
|
501
|
-
self._label_v_margin = 3
|
|
1623
|
+
self._pin_diameter = 4
|
|
1624
|
+
self._pin_margin = 3
|
|
1625
|
+
self._attach_spacing = 4
|
|
1626
|
+
self._label_bar_width = 4
|
|
1627
|
+
self._label_v_margin = 3
|
|
502
1628
|
|
|
503
1629
|
# Manual child indent to keep hierarchy visible when view indentation is 0
|
|
504
1630
|
self._child_indent = 15
|
|
@@ -507,13 +1633,19 @@ class ImportantItemDelegate(QtWidgets.QStyledItemDelegate):
|
|
|
507
1633
|
self._group_indicator_enabled = True
|
|
508
1634
|
self._group_indicator_width = 2
|
|
509
1635
|
self._group_indicator_color = QColor(67, 75, 78) # soft gray
|
|
510
|
-
self._group_indicator_gap = 6
|
|
1636
|
+
self._group_indicator_gap = 6
|
|
511
1637
|
self._group_indicator_bottom_offset = 6
|
|
512
1638
|
|
|
513
|
-
# Pinned icon sizing
|
|
514
|
-
# The actual painted size is min(max_size, availableHeightWithMargins)
|
|
1639
|
+
# Pinned icon sizing
|
|
515
1640
|
self._pin_icon_max_size = 12 # px
|
|
516
1641
|
|
|
1642
|
+
# Right-aligned counter for group rows
|
|
1643
|
+
self._group_count_left_gap = 12
|
|
1644
|
+
self._group_count_right_margin = 8
|
|
1645
|
+
self._group_count_color = QColor(128, 128, 128)
|
|
1646
|
+
# Extra padding so wide values like "999" are never cramped
|
|
1647
|
+
self._group_count_extra_pad = 4
|
|
1648
|
+
|
|
517
1649
|
# Try to load customization from application config (safe if missing)
|
|
518
1650
|
self._init_group_indicator_from_config()
|
|
519
1651
|
|
|
@@ -612,19 +1744,80 @@ class ImportantItemDelegate(QtWidgets.QStyledItemDelegate):
|
|
|
612
1744
|
except Exception:
|
|
613
1745
|
is_group = False
|
|
614
1746
|
|
|
615
|
-
# Default painting:
|
|
616
|
-
# - For groups: translate painter -8 px to push folder/icon closer to the left edge.
|
|
617
|
-
# - For others: paint normally.
|
|
618
1747
|
if is_group:
|
|
1748
|
+
# Fetch group metadata stored in UserRole
|
|
1749
|
+
data = index.data(QtCore.Qt.ItemDataRole.UserRole) or {}
|
|
1750
|
+
count = int(data.get("count", 0)) if isinstance(data, dict) and "count" in data else 0
|
|
1751
|
+
has_attachment = bool(data.get("is_attachment", False))
|
|
1752
|
+
|
|
1753
|
+
fm = option.fontMetrics
|
|
1754
|
+
icon_size = option.decorationSize or QtCore.QSize(16, 16)
|
|
1755
|
+
|
|
1756
|
+
count_text = str(count) if count > 0 else ""
|
|
1757
|
+
has_count = bool(count_text)
|
|
1758
|
+
count_w = fm.horizontalAdvance(count_text) if has_count else 0
|
|
1759
|
+
|
|
1760
|
+
# Compute reserved right-side space:
|
|
1761
|
+
# right margin + [counter width + pad] + [icon + spacing] + left gap
|
|
1762
|
+
reserve = self._group_count_right_margin
|
|
1763
|
+
if has_count:
|
|
1764
|
+
reserve += count_w + self._group_count_extra_pad
|
|
1765
|
+
if has_attachment:
|
|
1766
|
+
reserve += icon_size.width()
|
|
1767
|
+
if has_count:
|
|
1768
|
+
reserve += self._attach_spacing
|
|
1769
|
+
if has_count or has_attachment:
|
|
1770
|
+
reserve += self._group_count_left_gap
|
|
1771
|
+
|
|
1772
|
+
opt = QtWidgets.QStyleOptionViewItem(option)
|
|
1773
|
+
if reserve > 0:
|
|
1774
|
+
opt.rect = opt.rect.adjusted(0, 0, -int(reserve), 0)
|
|
1775
|
+
|
|
1776
|
+
# Paint base content
|
|
619
1777
|
painter.save()
|
|
620
1778
|
painter.translate(-2, 0)
|
|
621
|
-
super(ImportantItemDelegate, self).paint(painter,
|
|
1779
|
+
super(ImportantItemDelegate, self).paint(painter, opt, index)
|
|
1780
|
+
painter.restore()
|
|
1781
|
+
|
|
1782
|
+
# Draw right-side widgets with the required order:
|
|
1783
|
+
# attachment icon first (to the left), counter always flush to the far right.
|
|
1784
|
+
painter.save()
|
|
1785
|
+
right_edge = option.rect.right()
|
|
1786
|
+
top = option.rect.top()
|
|
1787
|
+
height = option.rect.height()
|
|
1788
|
+
|
|
1789
|
+
count_rect = None
|
|
1790
|
+
if has_count:
|
|
1791
|
+
count_right = right_edge - self._group_count_right_margin
|
|
1792
|
+
# Constrain counter area to avoid conflicting with left content/gap
|
|
1793
|
+
min_left = opt.rect.right() + self._group_count_left_gap
|
|
1794
|
+
count_width = count_w + self._group_count_extra_pad
|
|
1795
|
+
count_left = max(min_left, count_right - count_width)
|
|
1796
|
+
count_rect = QtCore.QRect(
|
|
1797
|
+
count_left,
|
|
1798
|
+
top,
|
|
1799
|
+
max(0, count_right - count_left),
|
|
1800
|
+
height
|
|
1801
|
+
)
|
|
1802
|
+
painter.setPen(self._group_count_color)
|
|
1803
|
+
painter.drawText(count_rect, QtCore.Qt.AlignVCenter | QtCore.Qt.AlignRight, count_text)
|
|
1804
|
+
|
|
1805
|
+
if has_attachment:
|
|
1806
|
+
if count_rect is not None:
|
|
1807
|
+
icon_right = count_rect.left() - self._attach_spacing
|
|
1808
|
+
else:
|
|
1809
|
+
icon_right = right_edge - self._group_count_right_margin
|
|
1810
|
+
icon_x = icon_right - icon_size.width()
|
|
1811
|
+
icon_y = top + (height - icon_size.height()) // 2
|
|
1812
|
+
icon_rect = QtCore.QRect(icon_x, icon_y, icon_size.width(), icon_size.height())
|
|
1813
|
+
self._attachment_icon.paint(painter, icon_rect, QtCore.Qt.AlignCenter)
|
|
1814
|
+
|
|
622
1815
|
painter.restore()
|
|
623
1816
|
else:
|
|
1817
|
+
# Default painting for non-group rows
|
|
624
1818
|
super(ImportantItemDelegate, self).paint(painter, option, index)
|
|
625
1819
|
|
|
626
|
-
# Group enclosure indicator (left bar
|
|
627
|
-
# This applies only to child rows (i.e., when a group is expanded).
|
|
1820
|
+
# Group enclosure indicator (left bar) for child rows
|
|
628
1821
|
if self._group_indicator_enabled and not is_group and is_child and self._group_indicator_width > 0:
|
|
629
1822
|
try:
|
|
630
1823
|
painter.save()
|
|
@@ -638,14 +1831,12 @@ class ImportantItemDelegate(QtWidgets.QStyledItemDelegate):
|
|
|
638
1831
|
# Place the bar to the LEFT of the child content area, leaving a small gap.
|
|
639
1832
|
child_left = option.rect.x()
|
|
640
1833
|
bar_w = self._group_indicator_width
|
|
641
|
-
# Left edge of the vertical bar (never below 0)
|
|
642
1834
|
vbar_left = max(0, child_left - (self._group_indicator_gap + bar_w))
|
|
643
1835
|
vbar_rect = QtCore.QRect(vbar_left, option.rect.y(), bar_w, option.rect.height())
|
|
644
1836
|
painter.drawRect(vbar_rect)
|
|
645
1837
|
|
|
646
1838
|
painter.restore()
|
|
647
1839
|
except Exception:
|
|
648
|
-
# Fail-safe: do not block painting if anything goes wrong
|
|
649
1840
|
pass
|
|
650
1841
|
|
|
651
1842
|
# Custom data painting for non-group items only (labels, pinned, attachments).
|
|
@@ -659,7 +1850,6 @@ class ImportantItemDelegate(QtWidgets.QStyledItemDelegate):
|
|
|
659
1850
|
painter.save()
|
|
660
1851
|
|
|
661
1852
|
# Draw attachment icon on the right (centered vertically).
|
|
662
|
-
# This is painted first, so the pin can overlay it when needed.
|
|
663
1853
|
icon_size = option.decorationSize or QtCore.QSize(16, 16)
|
|
664
1854
|
if is_attachment:
|
|
665
1855
|
icon_pos_x = option.rect.right() - icon_size.width()
|
|
@@ -672,22 +1862,15 @@ class ImportantItemDelegate(QtWidgets.QStyledItemDelegate):
|
|
|
672
1862
|
)
|
|
673
1863
|
self._attachment_icon.paint(painter, icon_rect, QtCore.Qt.AlignCenter)
|
|
674
1864
|
|
|
675
|
-
# Pinned indicator
|
|
676
|
-
# It overlays above any other right-side icons.
|
|
1865
|
+
# Pinned indicator at top-right
|
|
677
1866
|
if is_important:
|
|
678
1867
|
painter.setRenderHint(QtGui.QPainter.Antialiasing, True)
|
|
679
1868
|
painter.setCompositionMode(QtGui.QPainter.CompositionMode_SourceOver)
|
|
680
|
-
|
|
681
|
-
# Compute a compact size similar in footprint to previous circle,
|
|
682
|
-
# but readable for vector icon; clamp to available height.
|
|
683
1869
|
available = max(8, option.rect.height() - 2 * self._pin_margin)
|
|
684
1870
|
pin_size = min(self._pin_icon_max_size, available)
|
|
685
|
-
|
|
686
1871
|
x = option.rect.right() - self._pin_margin - pin_size
|
|
687
1872
|
y = option.rect.top() + self._pin_margin
|
|
688
1873
|
pin_rect = QtCore.QRect(x, y, pin_size, pin_size)
|
|
689
|
-
|
|
690
|
-
# Paint the pin icon (transparent background)
|
|
691
1874
|
self._pin_icon.paint(painter, pin_rect, QtCore.Qt.AlignCenter)
|
|
692
1875
|
|
|
693
1876
|
# Label bar on the left with 3px vertical margins
|