pygpt-net 2.6.67__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 (69) hide show
  1. pygpt_net/CHANGELOG.txt +12 -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/ctx/common.py +27 -17
  11. pygpt_net/controller/ctx/ctx.py +182 -101
  12. pygpt_net/controller/files/files.py +101 -41
  13. pygpt_net/controller/idx/indexer.py +87 -31
  14. pygpt_net/controller/kernel/kernel.py +13 -2
  15. pygpt_net/controller/mode/mode.py +3 -3
  16. pygpt_net/controller/model/editor.py +70 -15
  17. pygpt_net/controller/model/importer.py +153 -54
  18. pygpt_net/controller/painter/painter.py +2 -2
  19. pygpt_net/controller/presets/experts.py +68 -15
  20. pygpt_net/controller/presets/presets.py +72 -36
  21. pygpt_net/controller/settings/profile.py +76 -35
  22. pygpt_net/controller/settings/workdir.py +70 -39
  23. pygpt_net/core/assistants/files.py +20 -18
  24. pygpt_net/core/filesystem/actions.py +111 -10
  25. pygpt_net/core/filesystem/filesystem.py +2 -1
  26. pygpt_net/core/idx/idx.py +12 -11
  27. pygpt_net/core/idx/worker.py +13 -1
  28. pygpt_net/core/models/models.py +4 -4
  29. pygpt_net/core/profile/profile.py +13 -3
  30. pygpt_net/data/config/config.json +3 -3
  31. pygpt_net/data/config/models.json +3 -3
  32. pygpt_net/data/css/style.dark.css +39 -1
  33. pygpt_net/data/css/style.light.css +39 -1
  34. pygpt_net/data/locale/locale.de.ini +3 -1
  35. pygpt_net/data/locale/locale.en.ini +3 -1
  36. pygpt_net/data/locale/locale.es.ini +3 -1
  37. pygpt_net/data/locale/locale.fr.ini +3 -1
  38. pygpt_net/data/locale/locale.it.ini +3 -1
  39. pygpt_net/data/locale/locale.pl.ini +4 -2
  40. pygpt_net/data/locale/locale.uk.ini +3 -1
  41. pygpt_net/data/locale/locale.zh.ini +3 -1
  42. pygpt_net/provider/api/openai/__init__.py +4 -2
  43. pygpt_net/provider/core/config/patch.py +9 -1
  44. pygpt_net/tools/image_viewer/tool.py +17 -0
  45. pygpt_net/tools/text_editor/tool.py +9 -0
  46. pygpt_net/ui/__init__.py +2 -2
  47. pygpt_net/ui/layout/ctx/ctx_list.py +16 -6
  48. pygpt_net/ui/main.py +3 -1
  49. pygpt_net/ui/widget/calendar/select.py +3 -3
  50. pygpt_net/ui/widget/filesystem/explorer.py +1082 -142
  51. pygpt_net/ui/widget/lists/assistant.py +185 -24
  52. pygpt_net/ui/widget/lists/assistant_store.py +245 -42
  53. pygpt_net/ui/widget/lists/attachment.py +230 -47
  54. pygpt_net/ui/widget/lists/attachment_ctx.py +189 -33
  55. pygpt_net/ui/widget/lists/base_list_combo.py +2 -2
  56. pygpt_net/ui/widget/lists/context.py +1253 -70
  57. pygpt_net/ui/widget/lists/experts.py +110 -8
  58. pygpt_net/ui/widget/lists/model_editor.py +217 -14
  59. pygpt_net/ui/widget/lists/model_importer.py +125 -6
  60. pygpt_net/ui/widget/lists/preset.py +460 -71
  61. pygpt_net/ui/widget/lists/profile.py +149 -27
  62. pygpt_net/ui/widget/lists/uploaded.py +230 -38
  63. pygpt_net/ui/widget/option/combo.py +1046 -32
  64. pygpt_net/ui/widget/option/dictionary.py +35 -7
  65. {pygpt_net-2.6.67.dist-info → pygpt_net-2.7.0.dist-info}/METADATA +14 -57
  66. {pygpt_net-2.6.67.dist-info → pygpt_net-2.7.0.dist-info}/RECORD +69 -69
  67. {pygpt_net-2.6.67.dist-info → pygpt_net-2.7.0.dist-info}/LICENSE +0 -0
  68. {pygpt_net-2.6.67.dist-info → pygpt_net-2.7.0.dist-info}/WHEEL +0 -0
  69. {pygpt_net-2.6.67.dist-info → pygpt_net-2.7.0.dist-info}/entry_points.txt +0 -0
@@ -6,7 +6,7 @@
6
6
  # GitHub: https://github.com/szczyglis-dev/py-gpt #
7
7
  # MIT License #
8
8
  # Created By : Marcin Szczygliński #
9
- # Updated Date: 2025.09.26 12:00:00 #
9
+ # Updated Date: 2025.12.27 21:00:00 #
10
10
  # ================================================== #
11
11
 
12
12
  from PySide6.QtCore import QPoint, QItemSelectionModel, Qt, QEventLoop, QTimer, QMimeData
@@ -17,6 +17,7 @@ from pygpt_net.core.types import (
17
17
  MODE_EXPERT,
18
18
  )
19
19
  from pygpt_net.ui.widget.lists.base import BaseList
20
+ # Keep imports minimal and unchanged
20
21
  from pygpt_net.utils import trans
21
22
 
22
23
 
@@ -56,7 +57,8 @@ class PresetList(BaseList):
56
57
  self.setItemsExpandable(False)
57
58
  self.setUniformRowHeights(True)
58
59
  self.setSelectionBehavior(QAbstractItemView.SelectRows)
59
- self.setSelectionMode(QAbstractItemView.SingleSelection)
60
+ # ExtendedSelection enables Ctrl/Shift multi-select gestures
61
+ self.setSelectionMode(QAbstractItemView.ExtendedSelection)
60
62
 
61
63
  # Drag & drop state
62
64
  self._dnd_enabled = False
@@ -75,6 +77,14 @@ class PresetList(BaseList):
75
77
  self._dragging = False
76
78
  self._dragged_was_selected = False
77
79
 
80
+ # Virtual multi-select: suppress single-item business click once (Ctrl/Shift)
81
+ self._suppress_item_click = False
82
+ # Flag for "virtual" Ctrl multi-select gesture
83
+ self._ctrl_multi_active = False
84
+ self._ctrl_multi_index = None
85
+ # Guard to detect Shift-click range selection and bypass single-select follow-ups
86
+ self._was_shift_click = False
87
+
78
88
  # Mark that we already applied selection at drag start (one-shot per DnD)
79
89
  self._drag_selection_applied = False
80
90
 
@@ -105,6 +115,14 @@ class PresetList(BaseList):
105
115
  self._pending_scroll_value = None
106
116
  self._pending_refocus_role_id = None
107
117
 
118
+ # RMB-anchored scroll guard (preserve viewport after destructive updates from context menu)
119
+ self._rmb_anchor_scroll_value = None
120
+ self._scroll_guard_active = False
121
+ self._pre_update_scroll_value = 0
122
+ self._connected_model = None
123
+ self._model_signals_connected = False
124
+ self._connect_model_signals_safely()
125
+
108
126
  # -------- Public helpers to protect updates --------
109
127
 
110
128
  def begin_model_update(self):
@@ -157,7 +175,7 @@ class PresetList(BaseList):
157
175
  self._scroll_freeze_timer.start(max(50, int(ms)))
158
176
 
159
177
  def _apply_pending_scroll(self):
160
- """Re-apply saved scroll position when frozen."""
178
+ """Re-apply saved scroll position when frozen or guard is active."""
161
179
  if self._pending_scroll_value is None:
162
180
  return
163
181
  try:
@@ -177,10 +195,10 @@ class PresetList(BaseList):
177
195
 
178
196
  def scrollTo(self, index, hint=QAbstractItemView.EnsureVisible):
179
197
  """
180
- Temporarily suppress automatic scrolling while frozen.
198
+ Temporarily suppress automatic scrolling while frozen or guard is active.
181
199
  This prevents list jumping when selection triggers scrollTo during refresh.
182
200
  """
183
- if self._scroll_freeze_depth > 0:
201
+ if self._scroll_freeze_depth > 0 or self._scroll_guard_active:
184
202
  self._apply_pending_scroll()
185
203
  return
186
204
  return super().scrollTo(index, hint)
@@ -224,6 +242,149 @@ class PresetList(BaseList):
224
242
  # Keep pending id for next attempt if apply failed
225
243
  pass
226
244
 
245
+ # --------------------------------------------------------------------------
246
+ # RMB-anchored scroll guard: capture scroll at context open and restore after delete
247
+ # --------------------------------------------------------------------------
248
+
249
+ def _connect_model_signals_safely(self):
250
+ """Connect model change signals once to preserve scroll after updates."""
251
+ model = self.model()
252
+ if model is None:
253
+ return
254
+ if self._connected_model is model and self._model_signals_connected:
255
+ return
256
+
257
+ if self._connected_model is not None and self._connected_model is not model:
258
+ try:
259
+ self._connected_model.rowsAboutToBeRemoved.disconnect(self._on_rows_about_to_be_removed)
260
+ except Exception:
261
+ pass
262
+ try:
263
+ self._connected_model.rowsRemoved.disconnect(self._on_rows_removed)
264
+ except Exception:
265
+ pass
266
+ try:
267
+ self._connected_model.modelAboutToBeReset.disconnect(self._on_model_about_to_be_reset)
268
+ except Exception:
269
+ pass
270
+ try:
271
+ self._connected_model.modelReset.disconnect(self._on_model_reset)
272
+ except Exception:
273
+ pass
274
+ try:
275
+ self._connected_model.layoutAboutToBeChanged.disconnect(self._on_layout_about_to_change)
276
+ except Exception:
277
+ pass
278
+ try:
279
+ self._connected_model.layoutChanged.disconnect(self._on_layout_changed)
280
+ except Exception:
281
+ pass
282
+
283
+ try:
284
+ model.rowsAboutToBeRemoved.connect(self._on_rows_about_to_be_removed)
285
+ except Exception:
286
+ pass
287
+ try:
288
+ model.rowsRemoved.connect(self._on_rows_removed)
289
+ except Exception:
290
+ pass
291
+ try:
292
+ model.modelAboutToBeReset.connect(self._on_model_about_to_be_reset)
293
+ except Exception:
294
+ pass
295
+ try:
296
+ model.modelReset.connect(self._on_model_reset)
297
+ except Exception:
298
+ pass
299
+ try:
300
+ model.layoutAboutToBeChanged.connect(self._on_layout_about_to_change)
301
+ except Exception:
302
+ pass
303
+ try:
304
+ model.layoutChanged.connect(self._on_layout_changed)
305
+ except Exception:
306
+ pass
307
+
308
+ self._connected_model = model
309
+ self._model_signals_connected = True
310
+
311
+ def setModel(self, model):
312
+ """Reconnect model signals when the view's model is replaced."""
313
+ super().setModel(model)
314
+ self._connect_model_signals_safely()
315
+
316
+ def _activate_scroll_guard(self, override_value: int | None = None):
317
+ """Arm guard with provided anchor value (or current) and keep it until updates settle."""
318
+ try:
319
+ sb = self.verticalScrollBar()
320
+ except Exception:
321
+ sb = None
322
+ val = None
323
+ if override_value is not None:
324
+ val = int(override_value)
325
+ elif sb is not None:
326
+ val = sb.value()
327
+ if val is None:
328
+ val = 0
329
+ self._pre_update_scroll_value = val
330
+ self._pending_scroll_value = val
331
+ self._scroll_guard_active = True
332
+
333
+ def _schedule_scroll_restore(self):
334
+ """Restore anchored scroll repeatedly to outlast selection-driven scrolls."""
335
+ if not self._scroll_guard_active:
336
+ return
337
+
338
+ def apply():
339
+ try:
340
+ sb = self.verticalScrollBar()
341
+ if sb is None:
342
+ return
343
+ target = min(self._pre_update_scroll_value, sb.maximum())
344
+ sb.setValue(target)
345
+ self._pending_scroll_value = target
346
+ except Exception:
347
+ pass
348
+
349
+ QTimer.singleShot(0, apply)
350
+ QTimer.singleShot(25, apply)
351
+ QTimer.singleShot(75, apply)
352
+ QTimer.singleShot(150, apply)
353
+ QTimer.singleShot(300, apply)
354
+ QTimer.singleShot(600, self._clear_scroll_guard)
355
+
356
+ def _clear_scroll_guard(self):
357
+ """Clear RMB anchor guard."""
358
+ self._scroll_guard_active = False
359
+ self._rmb_anchor_scroll_value = None
360
+
361
+ # Model signal handlers
362
+
363
+ def _on_rows_about_to_be_removed(self, parent, start, end):
364
+ if self._scroll_guard_active:
365
+ # guard already armed by action; nothing else to do
366
+ return
367
+
368
+ def _on_rows_removed(self, parent, start, end):
369
+ if self._scroll_guard_active:
370
+ self._schedule_scroll_restore()
371
+
372
+ def _on_model_about_to_be_reset(self):
373
+ if self._scroll_guard_active:
374
+ return
375
+
376
+ def _on_model_reset(self):
377
+ if self._scroll_guard_active:
378
+ self._schedule_scroll_restore()
379
+
380
+ def _on_layout_about_to_change(self):
381
+ if self._scroll_guard_active:
382
+ return
383
+
384
+ def _on_layout_changed(self):
385
+ if self._scroll_guard_active:
386
+ self._schedule_scroll_restore()
387
+
227
388
  # --------------------------------------------------------------------------
228
389
 
229
390
  def set_dnd_enabled(self, enabled: bool):
@@ -303,6 +464,16 @@ class PresetList(BaseList):
303
464
  """Row click handler; select by ID (stable under reordering)."""
304
465
  if self._model_updating:
305
466
  return
467
+
468
+ # Suppress business click after virtual Ctrl/Shift selection
469
+ if self._suppress_item_click:
470
+ self._suppress_item_click = False
471
+ return
472
+
473
+ # Ignore business click if multiple rows are selected
474
+ if self._has_multi_selection():
475
+ return
476
+
306
477
  index = val
307
478
  if not index.isValid():
308
479
  return
@@ -333,70 +504,184 @@ class PresetList(BaseList):
333
504
  if row >= 0:
334
505
  self.window.controller.presets.editor.edit(row)
335
506
 
507
+ # ----------------------------
508
+ # Selection helpers (multi / single)
509
+ # ----------------------------
510
+
511
+ def _selected_indexes(self):
512
+ """Return list of selected row indexes (column 0)."""
513
+ try:
514
+ return list(self.selectionModel().selectedRows())
515
+ except Exception:
516
+ return []
517
+
518
+ def _selected_rows(self) -> list[int]:
519
+ """Return list of selected row numbers."""
520
+ try:
521
+ return [ix.row() for ix in self.selectionModel().selectedRows()]
522
+ except Exception:
523
+ return []
524
+
525
+ def _selected_role_ids(self) -> list[str]:
526
+ """Return list of selected ROLE_ID (stable IDs)."""
527
+ try:
528
+ out = []
529
+ for ix in self.selectionModel().selectedRows():
530
+ pid = ix.data(self.ROLE_ID)
531
+ if pid:
532
+ out.append(pid)
533
+ return out
534
+ except Exception:
535
+ return []
536
+
537
+ def _has_multi_selection(self) -> bool:
538
+ """Check whether more than one row is selected."""
539
+ try:
540
+ return len(self.selectionModel().selectedRows()) > 1
541
+ except Exception:
542
+ return False
543
+
544
+ # ----------------------------
545
+ # Context menu
546
+ # ----------------------------
547
+
336
548
  def show_context_menu(self, pos: QPoint):
337
549
  """Context menu event"""
338
550
  if self._model_updating:
339
551
  return
552
+
553
+ # Capture RMB anchor scroll value at the moment of opening the context menu
554
+ try:
555
+ self._rmb_anchor_scroll_value = self.verticalScrollBar().value()
556
+ except Exception:
557
+ self._rmb_anchor_scroll_value = None
558
+
340
559
  global_pos = self.viewport().mapToGlobal(pos)
341
560
  mode = self.window.core.config.get('mode')
342
561
  index = self.indexAt(pos)
343
- idx = index.row()
562
+ idx = index.row() if index.isValid() else -1
563
+
564
+ # Gather selection state
565
+ selected_idx_list = self._selected_indexes()
566
+ selected_ids = [ix.data(self.ROLE_ID) for ix in selected_idx_list if ix.data(self.ROLE_ID)]
567
+ selected_rows = [ix.row() for ix in selected_idx_list]
568
+ multi = len(selected_rows) > 1
344
569
 
570
+ # Allow menu on empty area only when multi-selection is active
571
+ if not index.isValid() and not multi:
572
+ return
573
+
574
+ # Resolve clicked item (for single)
345
575
  preset = None
346
- preset_id = None
347
576
  if idx >= 0:
348
577
  preset_id = self.window.core.presets.get_by_idx(idx, mode)
349
578
  if preset_id:
350
579
  preset = self.window.core.presets.items.get(preset_id)
351
580
 
352
- is_current = idx >= 0 and self.window.controller.presets.is_current(idx)
353
- is_special = bool(index.data(self.ROLE_IS_SPECIAL)) if index.isValid() else False
354
-
355
- if idx >= 0:
356
- menu = QMenu(self)
357
-
358
- edit_act = QAction(self._ICO_EDIT, trans('preset.action.edit'), menu)
359
- edit_act.triggered.connect(lambda checked=False, it=index: self.action_edit(it))
360
- menu.addAction(edit_act)
361
-
362
- if mode == MODE_EXPERT and preset and not preset.filename.startswith("current."):
363
- if not preset.enabled:
581
+ # Determine special/current flags for single target
582
+ is_current_single = idx >= 0 and self.window.controller.presets.is_current(idx)
583
+ is_special_single = bool(index.data(self.ROLE_IS_SPECIAL)) if index.isValid() else False
584
+
585
+ # Build menu
586
+ menu = QMenu(self)
587
+
588
+ # Edit (only for single)
589
+ edit_act = QAction(self._ICO_EDIT, trans('preset.action.edit'), menu)
590
+ edit_act.triggered.connect(lambda checked=False, it=index: self.action_edit(it))
591
+ edit_act.setEnabled(idx >= 0 and not multi)
592
+ menu.addAction(edit_act)
593
+
594
+ # Enable / Disable (single or multi, expert mode; ignore current.*)
595
+ if mode == MODE_EXPERT:
596
+ if multi:
597
+ items = self.window.core.presets.items
598
+ any_enable = False
599
+ any_disable = False
600
+ for ix in selected_idx_list:
601
+ pid = ix.data(self.ROLE_ID)
602
+ if not pid:
603
+ continue
604
+ it = items.get(pid)
605
+ if not it:
606
+ continue
607
+ if getattr(it, "filename", "").startswith("current."):
608
+ continue
609
+ if getattr(it, "enabled", False):
610
+ any_disable = True
611
+ else:
612
+ any_enable = True
613
+ if any_enable:
364
614
  enable_act = QAction(self._ICO_CHECK, trans('preset.action.enable'), menu)
365
- enable_act.triggered.connect(lambda checked=False, it=index: self.action_enable(it))
615
+ enable_act.triggered.connect(lambda checked=False, ids=list(selected_ids): self.action_enable(ids))
366
616
  menu.addAction(enable_act)
367
- else:
617
+ if any_disable:
368
618
  disable_act = QAction(self._ICO_CLOSE, trans('preset.action.disable'), menu)
369
- disable_act.triggered.connect(lambda checked=False, it=index: self.action_disable(it))
619
+ disable_act.triggered.connect(lambda checked=False, ids=list(selected_ids): self.action_disable(ids))
370
620
  menu.addAction(disable_act)
371
-
372
- duplicate_act = QAction(self._ICO_COPY, trans('preset.action.duplicate'), menu)
621
+ else:
622
+ if preset and not getattr(preset, "filename", "").startswith("current."):
623
+ if not getattr(preset, "enabled", False):
624
+ enable_act = QAction(self._ICO_CHECK, trans('preset.action.enable'), menu)
625
+ enable_act.triggered.connect(lambda checked=False, it=index: self.action_enable(it))
626
+ menu.addAction(enable_act)
627
+ else:
628
+ disable_act = QAction(self._ICO_CLOSE, trans('preset.action.disable'), menu)
629
+ disable_act.triggered.connect(lambda checked=False, it=index: self.action_disable(it))
630
+ menu.addAction(disable_act)
631
+
632
+ # Duplicate (single or multi)
633
+ duplicate_act = QAction(self._ICO_COPY, trans('preset.action.duplicate'), menu)
634
+ if multi:
635
+ duplicate_act.triggered.connect(lambda checked=False, ids=list(selected_ids): self.action_duplicate(ids))
636
+ else:
373
637
  duplicate_act.triggered.connect(lambda checked=False, it=index: self.action_duplicate(it))
374
638
 
375
- if self._dnd_enabled and not is_current and not is_special:
376
- up_act = QAction(self._ICO_UP, trans('common.up'), menu)
377
- down_act = QAction(self._ICO_DOWN, trans('common.down'), menu)
378
- up_act.setEnabled(idx > 1)
379
- down_act.setEnabled(idx < (self.model().rowCount() - 1))
380
- up_act.triggered.connect(lambda checked=False, it=index: self.action_move_up(it))
381
- down_act.triggered.connect(lambda checked=False, it=index: self.action_move_down(it))
382
- menu.addAction(up_act)
383
- menu.addAction(down_act)
384
-
385
- if is_current:
386
- edit_act.setEnabled(False)
387
- restore_act = QAction(self._ICO_UNDO, trans('dialog.editor.btn.defaults'), menu)
388
- restore_act.triggered.connect(lambda checked=False, it=index: self.action_restore(it))
389
- menu.addAction(restore_act)
390
- menu.addAction(duplicate_act)
639
+ # Up/Down only for single, non-current, non-special, and when DnD is on
640
+ if not multi and self._dnd_enabled and not is_current_single and not is_special_single and idx >= 0:
641
+ up_act = QAction(self._ICO_UP, trans('common.up'), menu)
642
+ down_act = QAction(self._ICO_DOWN, trans('common.down'), menu)
643
+ up_act.setEnabled(idx > 1)
644
+ down_act.setEnabled(idx < (self.model().rowCount() - 1))
645
+ up_act.triggered.connect(lambda checked=False, it=index: self.action_move_up(it))
646
+ down_act.triggered.connect(lambda checked=False, it=index: self.action_move_down(it))
647
+ menu.addAction(up_act)
648
+ menu.addAction(down_act)
649
+
650
+ # Restore / Delete depending on current / multi
651
+ if not multi and is_current_single:
652
+ edit_act.setEnabled(False)
653
+ restore_act = QAction(self._ICO_UNDO, trans('dialog.editor.btn.defaults'), menu)
654
+ restore_act.triggered.connect(lambda checked=False, it=index: self.action_restore(it))
655
+ menu.addAction(restore_act)
656
+ menu.addAction(duplicate_act)
657
+ else:
658
+ # In multi-selection: disable delete if any current.* or special in selection
659
+ can_delete_all = True
660
+ if multi:
661
+ for ix in selected_idx_list:
662
+ pid = ix.data(self.ROLE_ID)
663
+ if not pid:
664
+ continue
665
+ if pid.startswith("current.") or bool(ix.data(self.ROLE_IS_SPECIAL)):
666
+ can_delete_all = False
667
+ break
668
+ else:
669
+ can_delete_all = idx >= 0 and not is_current_single
670
+
671
+ delete_act = QAction(self._ICO_DELETE, trans('preset.action.delete'), menu)
672
+ if multi:
673
+ delete_act.triggered.connect(lambda checked=False, ids=list(selected_ids): self.action_delete(ids))
391
674
  else:
392
- delete_act = QAction(self._ICO_DELETE, trans('preset.action.delete'), menu)
393
675
  delete_act.triggered.connect(lambda checked=False, it=index: self.action_delete(it))
394
- menu.addAction(duplicate_act)
395
- menu.addAction(delete_act)
676
+ delete_act.setEnabled(can_delete_all)
396
677
 
397
- self.selection = self.selectionModel().selection()
398
- menu.exec_(global_pos)
678
+ menu.addAction(duplicate_act)
679
+ menu.addAction(delete_act)
399
680
 
681
+ self.selection = self.selectionModel().selection()
682
+ menu.exec_(global_pos)
683
+
684
+ # Restore selection after context menu if needed
400
685
  self.store_scroll_position()
401
686
  if self.restore_after_ctx_menu:
402
687
  if self._backup_selection is not None:
@@ -410,6 +695,12 @@ class PresetList(BaseList):
410
695
  self.restore_after_ctx_menu = True
411
696
  self.restore_scroll_position()
412
697
 
698
+ # ----------------------------
699
+ # Context actions (single or multi)
700
+ # If 'item' is a QModelIndex -> single row (int will be passed to external code).
701
+ # If 'item' is a list/tuple -> multi; pass list of ROLE_ID strings to external code.
702
+ # ----------------------------
703
+
413
704
  def action_edit(self, item):
414
705
  idx = item.row()
415
706
  if idx >= 0:
@@ -417,12 +708,23 @@ class PresetList(BaseList):
417
708
  self.window.controller.presets.editor.edit(idx)
418
709
 
419
710
  def action_duplicate(self, item):
711
+ if isinstance(item, (list, tuple)):
712
+ self.restore_after_ctx_menu = False
713
+ self.window.controller.presets.duplicate(list(item))
714
+ return
420
715
  idx = item.row()
421
716
  if idx >= 0:
422
717
  self.restore_after_ctx_menu = False
423
718
  self.window.controller.presets.duplicate(idx)
424
719
 
425
720
  def action_delete(self, item):
721
+ # Preserve scroll at the RMB anchor position before delete
722
+ self._activate_scroll_guard(self._rmb_anchor_scroll_value)
723
+
724
+ if isinstance(item, (list, tuple)):
725
+ self.restore_after_ctx_menu = False
726
+ self.window.controller.presets.delete(list(item))
727
+ return
426
728
  idx = item.row()
427
729
  if idx >= 0:
428
730
  self.restore_after_ctx_menu = False
@@ -432,11 +734,17 @@ class PresetList(BaseList):
432
734
  self.window.controller.presets.restore()
433
735
 
434
736
  def action_enable(self, item):
737
+ if isinstance(item, (list, tuple)):
738
+ self.window.controller.presets.enable(list(item))
739
+ return
435
740
  idx = item.row()
436
741
  if idx >= 0:
437
742
  self.window.controller.presets.enable(idx)
438
743
 
439
744
  def action_disable(self, item):
745
+ if isinstance(item, (list, tuple)):
746
+ self.window.controller.presets.disable(list(item))
747
+ return
440
748
  idx = item.row()
441
749
  if idx >= 0:
442
750
  self.window.controller.presets.disable(idx)
@@ -834,12 +1142,48 @@ class PresetList(BaseList):
834
1142
  if self._model_updating:
835
1143
  event.ignore()
836
1144
  return
1145
+
1146
+ # Ctrl+Left: virtual toggle without business click
1147
+ if event.button() == Qt.LeftButton and (event.modifiers() & Qt.ControlModifier):
1148
+ idx = self.indexAt(self._mouse_event_point(event))
1149
+ if idx.isValid():
1150
+ self._ctrl_multi_active = True
1151
+ self._ctrl_multi_index = idx
1152
+ self._suppress_item_click = True
1153
+ event.accept()
1154
+ return
1155
+ self._suppress_item_click = True
1156
+ event.accept()
1157
+ return
1158
+
1159
+ # Shift+Left: let Qt perform range selection (anchor -> clicked), suppress business click
1160
+ if event.button() == Qt.LeftButton and (event.modifiers() & Qt.ShiftModifier):
1161
+ idx = self.indexAt(self._mouse_event_point(event))
1162
+ if idx.isValid():
1163
+ self._suppress_item_click = True
1164
+ self._was_shift_click = True
1165
+ super().mousePressEvent(event) # default range selection
1166
+ return
1167
+ # Shift on empty area -> ignore silently
1168
+ self._suppress_item_click = True
1169
+ self._was_shift_click = True
1170
+ event.accept()
1171
+ return
1172
+
837
1173
  if event.button() == Qt.LeftButton:
838
1174
  index = self.indexAt(self._mouse_event_point(event))
839
- if not index.isValid():
840
- return
1175
+ # When multiple are selected, a single plain click clears the multi-selection
1176
+ if self._has_multi_selection():
1177
+ sel_model = self.selectionModel()
1178
+ sel_model.clearSelection()
1179
+ if not index.isValid():
1180
+ event.accept()
1181
+ return
1182
+ # continue with default single selection for clicked row
1183
+
841
1184
  # Freeze scroll for a moment to prevent jumps caused by selection-triggered refresh
842
- self._freeze_scroll(250)
1185
+ if index.isValid():
1186
+ self._freeze_scroll(250)
843
1187
  if self._dnd_enabled:
844
1188
  sel_model = self.selectionModel()
845
1189
  self._press_backup_selection = list(sel_model.selectedIndexes())
@@ -859,22 +1203,29 @@ class PresetList(BaseList):
859
1203
  return
860
1204
  else:
861
1205
  super().mousePressEvent(event)
1206
+
862
1207
  elif event.button() == Qt.RightButton:
863
1208
  index = self.indexAt(self._mouse_event_point(event))
864
- if index.isValid():
865
- sel_model = self.selectionModel()
866
- # Save original IDs (before we temporarily select right-click row)
867
- self._ctx_menu_original_ids = []
868
- for ix in sel_model.selectedRows():
869
- pid = ix.data(self.ROLE_ID)
870
- if pid:
871
- self._ctx_menu_original_ids.append(pid)
1209
+ sel_model = self.selectionModel()
1210
+ selected_rows = [ix.row() for ix in sel_model.selectedRows()]
1211
+ multi = len(selected_rows) > 1
872
1212
 
873
- self._backup_selection = list(sel_model.selectedIndexes())
874
- sel_model.clearSelection()
875
- sel_model.select(
876
- index, QItemSelectionModel.Select | QItemSelectionModel.Rows
877
- )
1213
+ # Save original IDs only if we are going to change selection
1214
+ if index.isValid():
1215
+ if multi and index.row() in selected_rows:
1216
+ # Keep existing multi-selection; do not alter selection on right click
1217
+ self._backup_selection = None
1218
+ self._ctx_menu_original_ids = [ix.data(self.ROLE_ID) for ix in sel_model.selectedRows() if ix.data(self.ROLE_ID)]
1219
+ else:
1220
+ # Right-click outside current selection (or not multi) -> select the clicked row temporarily
1221
+ self._ctx_menu_original_ids = []
1222
+ for ix in sel_model.selectedRows():
1223
+ pid = ix.data(self.ROLE_ID)
1224
+ if pid:
1225
+ self._ctx_menu_original_ids.append(pid)
1226
+ self._backup_selection = list(sel_model.selectedIndexes())
1227
+ sel_model.clearSelection()
1228
+ sel_model.select(index, QItemSelectionModel.Select | QItemSelectionModel.Rows)
878
1229
  event.accept()
879
1230
  else:
880
1231
  super().mousePressEvent(event)
@@ -924,6 +1275,35 @@ class PresetList(BaseList):
924
1275
  if self._model_updating:
925
1276
  event.ignore()
926
1277
  return
1278
+
1279
+ # If the click was a Shift-based range selection, bypass single-select synchronization
1280
+ if event.button() == Qt.LeftButton and self._was_shift_click:
1281
+ self._was_shift_click = False
1282
+ # Let Qt finish its own release processing; do not run our single-select logic
1283
+ super().mouseReleaseEvent(event)
1284
+ return
1285
+
1286
+ # Finish "virtual" Ctrl toggle on same row
1287
+ if event.button() == Qt.LeftButton and self._ctrl_multi_active:
1288
+ try:
1289
+ idx = self.indexAt(self._mouse_event_point(event))
1290
+ if idx.isValid() and self._ctrl_multi_index and idx == self._ctrl_multi_index:
1291
+ sel_model = self.selectionModel()
1292
+ sel_model.select(idx, QItemSelectionModel.Toggle | QItemSelectionModel.Rows)
1293
+ finally:
1294
+ self._ctrl_multi_active = False
1295
+ self._ctrl_multi_index = None
1296
+ # do not emit business click after Ctrl path
1297
+ self._press_pos = None
1298
+ self._press_index = None
1299
+ self._press_backup_selection = None
1300
+ self._press_backup_current = None
1301
+ self._dragging = False
1302
+ self._dragged_was_selected = False
1303
+ self._drag_selection_applied = False
1304
+ event.accept()
1305
+ return
1306
+
927
1307
  try:
928
1308
  if self._dnd_enabled and event.button() == Qt.LeftButton:
929
1309
  self.unsetCursor()
@@ -931,17 +1311,21 @@ class PresetList(BaseList):
931
1311
  if not self._dragging:
932
1312
  idx = self.indexAt(self._mouse_event_point(event))
933
1313
  if idx.isValid():
934
- pid = idx.data(self.ROLE_ID)
935
- if pid:
936
- # Keep scroll stable also for this late selection path
937
- self._freeze_scroll(300)
938
- self._pending_refocus_role_id = pid
939
- self.window.controller.presets.select_by_id(pid)
940
- QTimer.singleShot(0, self._apply_pending_refocus)
941
- QTimer.singleShot(50, self._apply_pending_refocus)
1314
+ # Skip business selection if multi-selection is active (e.g., after Shift)
1315
+ if self._has_multi_selection():
1316
+ pass
942
1317
  else:
943
- self.setCurrentIndex(idx)
944
- self.window.controller.presets.select(idx.row())
1318
+ pid = idx.data(self.ROLE_ID)
1319
+ if pid:
1320
+ # Keep scroll stable also for this late selection path
1321
+ self._freeze_scroll(300)
1322
+ self._pending_refocus_role_id = pid
1323
+ self.window.controller.presets.select_by_id(pid)
1324
+ QTimer.singleShot(0, self._apply_pending_refocus)
1325
+ QTimer.singleShot(50, self._apply_pending_refocus)
1326
+ else:
1327
+ self.setCurrentIndex(idx)
1328
+ self.window.controller.presets.select(idx.row())
945
1329
  finally:
946
1330
  self._press_pos = None
947
1331
  self._press_index = None
@@ -1064,6 +1448,11 @@ class PresetList(BaseList):
1064
1448
  self.window.controller.presets.persist_order_for_mode(mode, uuids)
1065
1449
 
1066
1450
  def selectionCommand(self, index, event=None):
1451
+ """
1452
+ Selection command
1453
+ :param index: Index
1454
+ :param event: Event
1455
+ """
1067
1456
  # Prevent selection changes while model is updating (guards against stale indexes)
1068
1457
  if self._model_updating:
1069
1458
  return QItemSelectionModel.NoUpdate