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.
Files changed (81) hide show
  1. pygpt_net/CHANGELOG.txt +18 -0
  2. pygpt_net/__init__.py +3 -3
  3. pygpt_net/controller/assistant/assistant.py +13 -8
  4. pygpt_net/controller/assistant/batch.py +29 -15
  5. pygpt_net/controller/assistant/files.py +19 -14
  6. pygpt_net/controller/assistant/store.py +63 -41
  7. pygpt_net/controller/attachment/attachment.py +45 -35
  8. pygpt_net/controller/chat/attachment.py +50 -39
  9. pygpt_net/controller/config/field/dictionary.py +26 -14
  10. pygpt_net/controller/config/field/textarea.py +2 -2
  11. pygpt_net/controller/ctx/common.py +27 -17
  12. pygpt_net/controller/ctx/ctx.py +182 -101
  13. pygpt_net/controller/dialogs/info.py +2 -2
  14. pygpt_net/controller/files/files.py +101 -41
  15. pygpt_net/controller/idx/indexer.py +87 -31
  16. pygpt_net/controller/kernel/kernel.py +13 -2
  17. pygpt_net/controller/media/media.py +29 -1
  18. pygpt_net/controller/mode/mode.py +3 -3
  19. pygpt_net/controller/model/editor.py +141 -21
  20. pygpt_net/controller/model/importer.py +153 -54
  21. pygpt_net/controller/painter/painter.py +2 -2
  22. pygpt_net/controller/presets/experts.py +68 -15
  23. pygpt_net/controller/presets/presets.py +72 -36
  24. pygpt_net/controller/settings/editor.py +25 -1
  25. pygpt_net/controller/settings/profile.py +76 -35
  26. pygpt_net/controller/settings/workdir.py +70 -39
  27. pygpt_net/core/assistants/files.py +20 -18
  28. pygpt_net/core/filesystem/actions.py +111 -10
  29. pygpt_net/core/filesystem/filesystem.py +2 -1
  30. pygpt_net/core/idx/idx.py +12 -11
  31. pygpt_net/core/idx/worker.py +13 -1
  32. pygpt_net/core/models/models.py +4 -4
  33. pygpt_net/core/profile/profile.py +13 -3
  34. pygpt_net/core/types/image.py +10 -1
  35. pygpt_net/core/video/video.py +43 -3
  36. pygpt_net/data/config/config.json +3 -3
  37. pygpt_net/data/config/models.json +25 -14
  38. pygpt_net/data/css/style.dark.css +39 -1
  39. pygpt_net/data/css/style.light.css +39 -1
  40. pygpt_net/data/locale/locale.de.ini +4 -1
  41. pygpt_net/data/locale/locale.en.ini +4 -1
  42. pygpt_net/data/locale/locale.es.ini +4 -1
  43. pygpt_net/data/locale/locale.fr.ini +4 -1
  44. pygpt_net/data/locale/locale.it.ini +4 -1
  45. pygpt_net/data/locale/locale.pl.ini +5 -2
  46. pygpt_net/data/locale/locale.uk.ini +4 -1
  47. pygpt_net/data/locale/locale.zh.ini +4 -1
  48. pygpt_net/item/model.py +1 -1
  49. pygpt_net/provider/api/openai/__init__.py +4 -2
  50. pygpt_net/provider/api/openai/video.py +2 -2
  51. pygpt_net/provider/core/config/patch.py +9 -1
  52. pygpt_net/provider/core/model/patch.py +26 -1
  53. pygpt_net/tools/image_viewer/tool.py +17 -0
  54. pygpt_net/tools/text_editor/tool.py +9 -0
  55. pygpt_net/ui/__init__.py +2 -2
  56. pygpt_net/ui/dialog/models.py +10 -1
  57. pygpt_net/ui/layout/ctx/ctx_list.py +16 -6
  58. pygpt_net/ui/layout/toolbox/video.py +14 -6
  59. pygpt_net/ui/main.py +3 -1
  60. pygpt_net/ui/widget/calendar/select.py +3 -3
  61. pygpt_net/ui/widget/filesystem/explorer.py +1082 -142
  62. pygpt_net/ui/widget/lists/assistant.py +185 -24
  63. pygpt_net/ui/widget/lists/assistant_store.py +245 -42
  64. pygpt_net/ui/widget/lists/attachment.py +230 -47
  65. pygpt_net/ui/widget/lists/attachment_ctx.py +189 -33
  66. pygpt_net/ui/widget/lists/base_list_combo.py +2 -2
  67. pygpt_net/ui/widget/lists/context.py +1253 -70
  68. pygpt_net/ui/widget/lists/experts.py +110 -8
  69. pygpt_net/ui/widget/lists/model_editor.py +217 -14
  70. pygpt_net/ui/widget/lists/model_importer.py +125 -6
  71. pygpt_net/ui/widget/lists/preset.py +460 -71
  72. pygpt_net/ui/widget/lists/profile.py +149 -27
  73. pygpt_net/ui/widget/lists/uploaded.py +230 -38
  74. pygpt_net/ui/widget/option/combo.py +1046 -32
  75. pygpt_net/ui/widget/option/dictionary.py +35 -7
  76. pygpt_net/ui/widget/option/input.py +3 -1
  77. {pygpt_net-2.6.66.dist-info → pygpt_net-2.7.0.dist-info}/METADATA +20 -57
  78. {pygpt_net-2.6.66.dist-info → pygpt_net-2.7.0.dist-info}/RECORD +81 -81
  79. {pygpt_net-2.6.66.dist-info → pygpt_net-2.7.0.dist-info}/LICENSE +0 -0
  80. {pygpt_net-2.6.66.dist-info → pygpt_net-2.7.0.dist-info}/WHEEL +0 -0
  81. {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.09.28 00:00:00 #
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 click(self, index):
502
+ def _selected_rows(self):
117
503
  """
118
- Click event (override, connected in BaseList class)
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
- :param index: index
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
- index = self.indexAt(event.pos())
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
- self.window.controller.ctx.unselect()
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
- sel = self.selectionModel()
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
- sel.clearSelection()
171
- sel.select(index, QItemSelectionModel.Select | QItemSelectionModel.Rows)
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
- global_pos = self.viewport().mapToGlobal(pos)
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: int, idx: int = None):
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: int, idx: int = None):
1210
+ def action_open_new_tab(self, id, idx: int = None):
350
1211
  """
351
- Open context action handler in nowej karcie
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: int, idx: int):
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: int):
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 tag action handler
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: int, label: int):
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 # legacy circle diameter (not used anymore)
498
- self._pin_margin = 3 # Margin from top and right edges
499
- self._attach_spacing = 4 # Kept for potential future layout tweaks
500
- self._label_bar_width = 4 # Full-height label bar width (left side)
501
- self._label_v_margin = 3 # 3px top/bottom margin for the label bar
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 # gap between child content left and the vertical bar
1636
+ self._group_indicator_gap = 6
511
1637
  self._group_indicator_bottom_offset = 6
512
1638
 
513
- # Pinned icon sizing (kept deliberately small, similar to previous yellow dot)
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, option, index)
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 + bottom bar on last child)
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: small pin.svg painted at fixed top-right position.
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