pygpt-net 2.6.61__py3-none-any.whl → 2.6.62__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (58) hide show
  1. pygpt_net/CHANGELOG.txt +7 -0
  2. pygpt_net/__init__.py +1 -1
  3. pygpt_net/controller/chat/response.py +8 -2
  4. pygpt_net/controller/settings/profile.py +16 -4
  5. pygpt_net/controller/settings/workdir.py +30 -5
  6. pygpt_net/controller/theme/common.py +4 -2
  7. pygpt_net/controller/theme/markdown.py +2 -2
  8. pygpt_net/controller/theme/theme.py +2 -1
  9. pygpt_net/controller/ui/ui.py +31 -3
  10. pygpt_net/core/agents/custom/llama_index/runner.py +18 -3
  11. pygpt_net/core/agents/custom/runner.py +10 -5
  12. pygpt_net/core/agents/runners/llama_workflow.py +65 -5
  13. pygpt_net/core/agents/runners/openai_workflow.py +2 -1
  14. pygpt_net/core/node_editor/types.py +13 -1
  15. pygpt_net/core/render/web/renderer.py +76 -11
  16. pygpt_net/data/config/config.json +2 -2
  17. pygpt_net/data/config/models.json +2 -2
  18. pygpt_net/data/css/style.dark.css +18 -0
  19. pygpt_net/data/css/style.light.css +20 -1
  20. pygpt_net/data/locale/locale.de.ini +2 -0
  21. pygpt_net/data/locale/locale.en.ini +2 -0
  22. pygpt_net/data/locale/locale.es.ini +2 -0
  23. pygpt_net/data/locale/locale.fr.ini +2 -0
  24. pygpt_net/data/locale/locale.it.ini +2 -0
  25. pygpt_net/data/locale/locale.pl.ini +3 -1
  26. pygpt_net/data/locale/locale.uk.ini +2 -0
  27. pygpt_net/data/locale/locale.zh.ini +2 -0
  28. pygpt_net/item/ctx.py +23 -1
  29. pygpt_net/provider/agents/llama_index/workflow/codeact.py +9 -6
  30. pygpt_net/provider/agents/llama_index/workflow/openai.py +38 -11
  31. pygpt_net/provider/agents/llama_index/workflow/planner.py +36 -16
  32. pygpt_net/provider/agents/llama_index/workflow/supervisor.py +60 -10
  33. pygpt_net/provider/agents/openai/agent.py +3 -1
  34. pygpt_net/provider/agents/openai/agent_b2b.py +13 -9
  35. pygpt_net/provider/agents/openai/agent_planner.py +6 -2
  36. pygpt_net/provider/agents/openai/agent_with_experts.py +4 -1
  37. pygpt_net/provider/agents/openai/agent_with_experts_feedback.py +4 -2
  38. pygpt_net/provider/agents/openai/agent_with_feedback.py +4 -2
  39. pygpt_net/provider/agents/openai/evolve.py +6 -2
  40. pygpt_net/provider/agents/openai/supervisor.py +3 -1
  41. pygpt_net/provider/api/openai/agents/response.py +1 -0
  42. pygpt_net/provider/core/config/patch.py +8 -0
  43. pygpt_net/tools/agent_builder/tool.py +6 -0
  44. pygpt_net/tools/agent_builder/ui/dialogs.py +0 -41
  45. pygpt_net/ui/layout/toolbox/presets.py +14 -2
  46. pygpt_net/ui/main.py +2 -2
  47. pygpt_net/ui/widget/dialog/confirm.py +27 -3
  48. pygpt_net/ui/widget/draw/painter.py +90 -1
  49. pygpt_net/ui/widget/lists/preset.py +289 -25
  50. pygpt_net/ui/widget/node_editor/editor.py +53 -15
  51. pygpt_net/ui/widget/node_editor/node.py +82 -104
  52. pygpt_net/ui/widget/node_editor/view.py +4 -5
  53. pygpt_net/ui/widget/textarea/input.py +155 -21
  54. {pygpt_net-2.6.61.dist-info → pygpt_net-2.6.62.dist-info}/METADATA +17 -8
  55. {pygpt_net-2.6.61.dist-info → pygpt_net-2.6.62.dist-info}/RECORD +58 -58
  56. {pygpt_net-2.6.61.dist-info → pygpt_net-2.6.62.dist-info}/LICENSE +0 -0
  57. {pygpt_net-2.6.61.dist-info → pygpt_net-2.6.62.dist-info}/WHEEL +0 -0
  58. {pygpt_net-2.6.61.dist-info → pygpt_net-2.6.62.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 03:00:00 #
9
+ # Updated Date: 2025.09.26 12:00:00 #
10
10
  # ================================================== #
11
11
 
12
12
  from PySide6.QtCore import QPoint, QItemSelectionModel, Qt, QEventLoop, QTimer, QMimeData
@@ -65,6 +65,7 @@ class PresetList(BaseList):
65
65
  self.setDragDropMode(QAbstractItemView.NoDragDrop) # switched dynamically
66
66
  self.setDefaultDropAction(Qt.MoveAction)
67
67
  self.setDragDropOverwriteMode(False)
68
+ # We use our own visual indicator for drop position
68
69
  self.setDropIndicatorShown(False)
69
70
 
70
71
  self._press_pos = None
@@ -92,6 +93,18 @@ class PresetList(BaseList):
92
93
  # One-shot forced selection after refresh (list of ROLE_ID)
93
94
  self._selection_override_ids = None
94
95
 
96
+ # Custom drop indicator (visual only)
97
+ self._drop_indicator_active = False
98
+ # seam row for indicator (row under which the line is drawn)
99
+ self._drop_indicator_to_row = -1
100
+ self._drop_indicator_padding = 6 # visual left/right padding
101
+
102
+ # Short-lived scroll freeze to prevent jumps during click-triggered model refresh
103
+ self._scroll_freeze_depth = 0
104
+ self._scroll_freeze_timer = None
105
+ self._pending_scroll_value = None
106
+ self._pending_refocus_role_id = None
107
+
95
108
  # -------- Public helpers to protect updates --------
96
109
 
97
110
  def begin_model_update(self):
@@ -103,26 +116,135 @@ class PresetList(BaseList):
103
116
  """Re-enable interaction after model/view rebuild is complete."""
104
117
  self.setEnabled(True)
105
118
  self._model_updating = False
119
+ # If there is a pending scroll/selection stabilization, apply it right after update
120
+ self._apply_pending_scroll()
121
+ self._apply_pending_refocus()
122
+ QTimer.singleShot(0, self._apply_pending_scroll)
123
+ QTimer.singleShot(0, self._apply_pending_refocus)
124
+ # Unfreeze shortly after everything settled in the event loop
125
+ QTimer.singleShot(50, self._unfreeze_scroll)
106
126
 
107
127
  # ---------------------------------------------------
108
128
 
129
+ # -------- Scroll freeze helpers (prevent accidental jumps on click) --------
130
+
131
+ def _freeze_scroll(self, ms: int = 250):
132
+ """
133
+ Freeze scrollTo() effects for a very short time and keep current scroll value.
134
+ This avoids jumps caused by programmatic scroll during selection/refresh.
135
+ """
136
+ try:
137
+ sb = self.verticalScrollBar()
138
+ except Exception:
139
+ sb = None
140
+ if sb is not None:
141
+ self._pending_scroll_value = sb.value()
142
+ self._scroll_freeze_depth += 1
143
+
144
+ # Apply stabilization now and on next frame(s)
145
+ QTimer.singleShot(0, self._apply_pending_scroll)
146
+ QTimer.singleShot(16, self._apply_pending_scroll)
147
+
148
+ # Auto-unfreeze after given duration
149
+ if self._scroll_freeze_timer:
150
+ try:
151
+ self._scroll_freeze_timer.stop()
152
+ except Exception:
153
+ pass
154
+ self._scroll_freeze_timer = QTimer(self)
155
+ self._scroll_freeze_timer.setSingleShot(True)
156
+ self._scroll_freeze_timer.timeout.connect(self._unfreeze_scroll)
157
+ self._scroll_freeze_timer.start(max(50, int(ms)))
158
+
159
+ def _apply_pending_scroll(self):
160
+ """Re-apply saved scroll position when frozen."""
161
+ if self._pending_scroll_value is None:
162
+ return
163
+ try:
164
+ sb = self.verticalScrollBar()
165
+ except Exception:
166
+ sb = None
167
+ if sb is not None:
168
+ sb.setValue(self._pending_scroll_value)
169
+
170
+ def _unfreeze_scroll(self):
171
+ """Release the temporary scroll freeze."""
172
+ if self._scroll_freeze_depth > 0:
173
+ self._scroll_freeze_depth -= 1
174
+ if self._scroll_freeze_depth <= 0:
175
+ self._scroll_freeze_depth = 0
176
+ self._pending_scroll_value = None
177
+
178
+ def scrollTo(self, index, hint=QAbstractItemView.EnsureVisible):
179
+ """
180
+ Temporarily suppress automatic scrolling while frozen.
181
+ This prevents list jumping when selection triggers scrollTo during refresh.
182
+ """
183
+ if self._scroll_freeze_depth > 0:
184
+ self._apply_pending_scroll()
185
+ return
186
+ return super().scrollTo(index, hint)
187
+
188
+ def _apply_pending_refocus(self):
189
+ """
190
+ Ensure selection stays on the intended item (by ROLE_ID) after a model refresh.
191
+ Does not force scrolling when scroll is frozen.
192
+ """
193
+ pid = self._pending_refocus_role_id
194
+ if not pid:
195
+ return
196
+ model = self.model()
197
+ if model is None:
198
+ return
199
+ target_idx = None
200
+ try:
201
+ for r in range(model.rowCount()):
202
+ ix = model.index(r, 0)
203
+ if ix.data(self.ROLE_ID) == pid:
204
+ target_idx = ix
205
+ break
206
+ except Exception:
207
+ target_idx = None
208
+
209
+ if target_idx is not None and target_idx.isValid():
210
+ try:
211
+ sel_model = self.selectionModel()
212
+ if sel_model:
213
+ prev_unlocked = getattr(self, "unlocked", True)
214
+ self.unlocked = True
215
+ try:
216
+ sel_model.clearSelection()
217
+ sel_model.select(target_idx, QItemSelectionModel.ClearAndSelect | QItemSelectionModel.Rows)
218
+ self.setCurrentIndex(target_idx)
219
+ finally:
220
+ self.unlocked = prev_unlocked
221
+ # If refocus succeeded, clear the pending marker
222
+ self._pending_refocus_role_id = None
223
+ except Exception:
224
+ # Keep pending id for next attempt if apply failed
225
+ pass
226
+
227
+ # --------------------------------------------------------------------------
228
+
109
229
  def set_dnd_enabled(self, enabled: bool):
110
230
  """
111
231
  Toggle DnD behaviour at runtime.
112
232
  Using DragDrop (not InternalMove) to avoid implicit Qt reordering.
233
+ We also disable the native drop indicator and render our own line.
113
234
  """
114
235
  self._dnd_enabled = bool(enabled)
115
236
  if self._dnd_enabled:
116
237
  self.setDragEnabled(True)
117
238
  self.setAcceptDrops(True)
118
239
  self.setDragDropMode(QAbstractItemView.DragDrop)
119
- self.setDropIndicatorShown(True)
240
+ self.setDropIndicatorShown(False) # use custom indicator
120
241
  else:
121
242
  self.setDragEnabled(False)
122
243
  self.setAcceptDrops(False)
123
244
  self.setDragDropMode(QAbstractItemView.NoDragDrop)
124
245
  self.setDropIndicatorShown(False)
125
246
  self.unsetCursor()
247
+ self._clear_drop_indicator() # ensure clean state
126
248
 
127
249
  def backup_selection(self):
128
250
  """
@@ -186,12 +308,21 @@ class PresetList(BaseList):
186
308
  return
187
309
  preset_id = index.data(self.ROLE_ID)
188
310
  if preset_id:
311
+ # Freeze scroll and remember the intended selection to re-apply after any refresh
312
+ self._freeze_scroll(300)
313
+ self._pending_refocus_role_id = preset_id
189
314
  self.window.controller.presets.select_by_id(preset_id)
315
+ # Re-apply selection in next ticks to win races with late refresh
316
+ QTimer.singleShot(0, self._apply_pending_refocus)
317
+ QTimer.singleShot(50, self._apply_pending_refocus)
190
318
  self.selection = self.selectionModel().selection()
191
319
  return
192
320
  row = index.row()
193
321
  if row >= 0:
322
+ self._freeze_scroll(300)
194
323
  self.window.controller.presets.select(row)
324
+ QTimer.singleShot(0, self._apply_pending_refocus)
325
+ QTimer.singleShot(50, self._apply_pending_refocus)
195
326
  self.selection = self.selectionModel().selection()
196
327
 
197
328
  def dblclick(self, val):
@@ -504,16 +635,13 @@ class PresetList(BaseList):
504
635
 
505
636
  self.store_scroll_position()
506
637
 
507
- di_prev = self._dnd_enabled
508
- self.setDropIndicatorShown(False)
638
+ # Use custom indicator only; do not re-enable native one here
509
639
  self.setUpdatesEnabled(False)
510
640
  try:
511
641
  self.window.controller.presets.update_list()
512
642
  self.restore_scroll_position()
513
643
  finally:
514
644
  self.setUpdatesEnabled(True)
515
- if di_prev and self._dnd_enabled:
516
- self.setDropIndicatorShown(True)
517
645
 
518
646
  # Clear helpers for context menu (layout will consume _selection_override_ids)
519
647
  self._ctx_menu_original_ids = None
@@ -556,6 +684,136 @@ class PresetList(BaseList):
556
684
  moved_id = self._reorder_and_persist(from_row, to_row)
557
685
  self._refresh_after_order_change(moved_id, follow_selection=False)
558
686
 
687
+ # --- Custom drop indicator helpers ---
688
+
689
+ def _compute_drop_locations(self, pos: QPoint) -> tuple[int, int]:
690
+ """
691
+ Compute both:
692
+ - to_row_drop: final insertion row used for reordering (after 'moving-down' adjustment),
693
+ - seam_row: row under which the visual indicator line should be drawn
694
+ in the current (pre-drop) view geometry.
695
+
696
+ This keeps visuals and the final insertion point perfectly aligned.
697
+
698
+ Returns: (to_row_drop, seam_row)
699
+ """
700
+ model = self.model()
701
+ if model is None:
702
+ return -1, -1
703
+
704
+ idx = self.indexAt(pos)
705
+
706
+ beyond_last = False
707
+ if not idx.isValid():
708
+ to_row_raw = model.rowCount() # append at the end
709
+ if model.rowCount() > 0:
710
+ last_idx = model.index(model.rowCount() - 1, 0)
711
+ last_rect = self.visualRect(last_idx)
712
+ if last_rect.isValid() and pos.y() > last_rect.bottom():
713
+ beyond_last = True
714
+ else:
715
+ rect = self.visualRect(idx)
716
+ to_row_raw = idx.row() + (1 if pos.y() > rect.center().y() else 0)
717
+
718
+ # Keep first row pinned (cannot insert above row 1)
719
+ if to_row_raw <= 1:
720
+ to_row_raw = 1
721
+
722
+ # seam row is always the boundary under the row at (to_row_raw - 1),
723
+ # except in explicit "beyond last" zone where we draw under the last row.
724
+ if model.rowCount() > 0:
725
+ if beyond_last:
726
+ seam_row = model.rowCount() - 1
727
+ else:
728
+ seam_row = max(0, min(model.rowCount() - 1, to_row_raw - 1))
729
+ else:
730
+ seam_row = -1
731
+
732
+ # Apply 'moving down' adjustment only to the logical insertion row,
733
+ # never to the visual seam (otherwise the line jumps one row up).
734
+ from_row = self._press_index.row() if (self._press_index and self._press_index.isValid()) else -1
735
+ to_row_drop = to_row_raw
736
+ if from_row >= 0 and to_row_raw > from_row and not beyond_last:
737
+ to_row_drop -= 1
738
+
739
+ # Clamp to valid ranges
740
+ to_row_drop = max(1, min(model.rowCount(), to_row_drop))
741
+ if seam_row >= 0:
742
+ seam_row = max(0, min(model.rowCount() - 1, seam_row))
743
+
744
+ return to_row_drop, seam_row
745
+
746
+ def _update_drop_indicator_from_pos(self, pos: QPoint):
747
+ """
748
+ Update custom drop indicator state based on cursor position.
749
+ Draws a single horizontal line under the row where the item will land.
750
+ """
751
+ if not self._dnd_enabled or self._model_updating:
752
+ self._clear_drop_indicator()
753
+ return
754
+
755
+ model = self.model()
756
+ if model is None or model.rowCount() <= 0:
757
+ self._clear_drop_indicator()
758
+ return
759
+
760
+ _, seam_row = self._compute_drop_locations(pos)
761
+ if seam_row < 0:
762
+ self._clear_drop_indicator()
763
+ return
764
+
765
+ if not self._drop_indicator_active or self._drop_indicator_to_row != seam_row:
766
+ self._drop_indicator_active = True
767
+ self._drop_indicator_to_row = seam_row
768
+ self.viewport().update()
769
+
770
+ def _clear_drop_indicator(self):
771
+ """Hide custom drop indicator."""
772
+ if self._drop_indicator_active or self._drop_indicator_to_row != -1:
773
+ self._drop_indicator_active = False
774
+ self._drop_indicator_to_row = -1
775
+ if self.viewport():
776
+ self.viewport().update()
777
+
778
+ def paintEvent(self, event):
779
+ """
780
+ Standard paint + overlay a clear drop indicator line at the computed insertion position.
781
+ """
782
+ super().paintEvent(event)
783
+
784
+ if not self._drop_indicator_active or not self._dnd_enabled:
785
+ return
786
+
787
+ model = self.model()
788
+ if model is None or model.rowCount() <= 0:
789
+ return
790
+
791
+ seam_row = self._drop_indicator_to_row
792
+ if seam_row < 0 or seam_row >= model.rowCount():
793
+ return
794
+
795
+ idx = model.index(seam_row, 0)
796
+ rect = self.visualRect(idx)
797
+ if not rect.isValid() or rect.height() <= 0:
798
+ return
799
+
800
+ # Line under the seam row
801
+ y = rect.bottom()
802
+ x1 = self._drop_indicator_padding
803
+ x2 = self.viewport().width() - self._drop_indicator_padding
804
+
805
+ painter = QPainter(self.viewport())
806
+ try:
807
+ # Use highlight color with good contrast; 1px thickness
808
+ color = self.palette().highlight().color()
809
+ color.setAlpha(220)
810
+ pen = QPen(color, 1)
811
+ pen.setCapStyle(Qt.RoundCap)
812
+ painter.setPen(pen)
813
+ painter.drawLine(x1, y, x2, y)
814
+ finally:
815
+ painter.end()
816
+
559
817
  # ----------------------------
560
818
  # Mouse / DnD events
561
819
  # ----------------------------
@@ -580,6 +838,8 @@ class PresetList(BaseList):
580
838
  index = self.indexAt(self._mouse_event_point(event))
581
839
  if not index.isValid():
582
840
  return
841
+ # Freeze scroll for a moment to prevent jumps caused by selection-triggered refresh
842
+ self._freeze_scroll(250)
583
843
  if self._dnd_enabled:
584
844
  sel_model = self.selectionModel()
585
845
  self._press_backup_selection = list(sel_model.selectedIndexes())
@@ -657,6 +917,7 @@ class PresetList(BaseList):
657
917
 
658
918
  self._dragging = True
659
919
  self.setCursor(QCursor(Qt.ClosedHandCursor))
920
+ # Let base class proceed; it will trigger startDrag when needed.
660
921
  super().mouseMoveEvent(event)
661
922
 
662
923
  def mouseReleaseEvent(self, event):
@@ -666,12 +927,18 @@ class PresetList(BaseList):
666
927
  try:
667
928
  if self._dnd_enabled and event.button() == Qt.LeftButton:
668
929
  self.unsetCursor()
930
+ self._clear_drop_indicator()
669
931
  if not self._dragging:
670
932
  idx = self.indexAt(self._mouse_event_point(event))
671
933
  if idx.isValid():
672
934
  pid = idx.data(self.ROLE_ID)
673
935
  if pid:
936
+ # Keep scroll stable also for this late selection path
937
+ self._freeze_scroll(300)
938
+ self._pending_refocus_role_id = pid
674
939
  self.window.controller.presets.select_by_id(pid)
940
+ QTimer.singleShot(0, self._apply_pending_refocus)
941
+ QTimer.singleShot(50, self._apply_pending_refocus)
675
942
  else:
676
943
  self.setCurrentIndex(idx)
677
944
  self.window.controller.presets.select(idx.row())
@@ -693,12 +960,16 @@ class PresetList(BaseList):
693
960
  return
694
961
  event.setDropAction(Qt.MoveAction)
695
962
  event.acceptProposedAction()
963
+ super().dragEnterEvent(event)
964
+ # Show indicator immediately on enter
965
+ self._update_drop_indicator_from_pos(self._mouse_event_point(event))
696
966
 
697
967
  def dragLeaveEvent(self, event):
698
968
  if self._model_updating:
699
969
  event.ignore()
700
970
  return
701
971
  self.unsetCursor()
972
+ self._clear_drop_indicator()
702
973
  super().dragLeaveEvent(event)
703
974
 
704
975
  def dragMoveEvent(self, event):
@@ -707,7 +978,6 @@ class PresetList(BaseList):
707
978
  return
708
979
  if not self._dnd_enabled:
709
980
  return
710
- event.setDropAction(Qt.MoveAction)
711
981
 
712
982
  pos = self._mouse_event_point(event)
713
983
  idx = self.indexAt(pos)
@@ -715,9 +985,17 @@ class PresetList(BaseList):
715
985
  if idx.isValid() and idx.row() == 0:
716
986
  rect = self.visualRect(idx)
717
987
  if pos.y() <= rect.center().y():
988
+ self._clear_drop_indicator()
718
989
  event.ignore()
719
990
  return
991
+
992
+ # Let base class process autoscroll and internal geometry first
993
+ event.setDropAction(Qt.MoveAction)
720
994
  event.acceptProposedAction()
995
+ super().dragMoveEvent(event)
996
+
997
+ # Update custom indicator based on current cursor and updated viewport
998
+ self._update_drop_indicator_from_pos(pos)
721
999
 
722
1000
  def dropEvent(self, event):
723
1001
  """
@@ -746,26 +1024,11 @@ class PresetList(BaseList):
746
1024
  event.ignore()
747
1025
  self.unsetCursor()
748
1026
  self._drag_selection_applied = False
1027
+ self._clear_drop_indicator()
749
1028
  return
750
1029
 
751
- # Target row
752
- pos = self._mouse_event_point(event)
753
- idx = self.indexAt(pos)
754
- if not idx.isValid():
755
- to_row = model.rowCount() # append
756
- else:
757
- rect = self.visualRect(idx)
758
- to_row = idx.row()
759
- if pos.y() > rect.center().y():
760
- to_row += 1
761
-
762
- # Keep first row pinned
763
- if to_row <= 1:
764
- to_row = 1
765
-
766
- # Adjust when moving down (Qt inserts before position)
767
- if to_row > from_row:
768
- to_row -= 1
1030
+ # Target row computed exactly the same way as the indicator (but with 'moving down' adjustment)
1031
+ to_row, _ = self._compute_drop_locations(self._mouse_event_point(event))
769
1032
 
770
1033
  moved_id = self._reorder_and_persist(from_row, to_row)
771
1034
 
@@ -778,6 +1041,7 @@ class PresetList(BaseList):
778
1041
  event.acceptProposedAction()
779
1042
  self.unsetCursor()
780
1043
  self._drag_selection_applied = False
1044
+ self._clear_drop_indicator()
781
1045
 
782
1046
  # ----------------------------
783
1047
  # Legacy helper (not used in new path)
@@ -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.25 12:35:00 #
9
+ # Updated Date: 2025.09.26 12:00:00 #
10
10
  # ================================================== #
11
11
 
12
12
  from __future__ import annotations
@@ -143,18 +143,6 @@ class NodeEditor(QWidget):
143
143
  # Centralized strings
144
144
  self.config: EditorConfig = config if isinstance(config, EditorConfig) else EditorConfig()
145
145
 
146
- # Theme and palette
147
- """
148
- if parent and hasattr(parent, "window"):
149
- theme = parent.window.core.config.get("theme")
150
- if theme.startswith("light"):
151
- print("[NodeEditor] Detected light theme from parent")
152
- self._grid_back_color = QColor(255, 255, 255)
153
- self._grid_pen_color = QColor(230, 230, 230)
154
- self.gridBackColor = Property(QColor, lambda self: self._grid_back_color, lambda self, v: self._q_set("_grid_back_color", v))
155
- self.gridPenColor = Property(QColor, lambda self: self._grid_pen_color, lambda self, v: self._q_set("_grid_pen_color", v))
156
- """
157
-
158
146
  self.graph = NodeGraph(registry)
159
147
  self.scene = NodeGraphicsScene(self)
160
148
  self.view = NodeGraphicsView(self.scene, self)
@@ -422,7 +410,12 @@ class NodeEditor(QWidget):
422
410
 
423
411
  def add_node(self, type_name: str, scene_pos: QPointF):
424
412
  """Add a new node of the given type at scene_pos (undoable)."""
413
+ # Enforce optional per-type limit configured in registry.
414
+ if not self._can_add_node_of_type(type_name):
415
+ self._dbg(f"add_node blocked: type='{type_name}' reached its max per-layout limit")
416
+ return False
425
417
  self._undo.push(AddNodeCommand(self, type_name, scene_pos))
418
+ return True
426
419
 
427
420
  def clear(self, ask_user: bool = True):
428
421
  """Clear the entire editor (undoable), optionally asking the user for confirmation."""
@@ -438,6 +431,7 @@ class NodeEditor(QWidget):
438
431
  if reply != QMessageBox.Yes:
439
432
  return False
440
433
  self._undo.push(ClearGraphCommand(self))
434
+ self._update_status_label()
441
435
  return True
442
436
 
443
437
  def undo(self):
@@ -668,6 +662,35 @@ class NodeEditor(QWidget):
668
662
  vp_pt = self.view.mapFromScene(scene_pos)
669
663
  return self.view.viewport().mapToGlobal(vp_pt)
670
664
 
665
+ # ---------- Per-type limit helpers ----------
666
+
667
+ def _type_limit(self, type_name: str) -> Optional[int]:
668
+ """Return configured max_num for type or None if unlimited."""
669
+ spec = self.graph.registry.get(type_name) if self.graph and self.graph.registry else None
670
+ if not spec:
671
+ return None
672
+ try:
673
+ limit = getattr(spec, "max_num", None)
674
+ if isinstance(limit, int) and limit <= 0:
675
+ return None
676
+ return limit if isinstance(limit, int) else None
677
+ except Exception:
678
+ return None
679
+
680
+ def _count_nodes_of_type(self, type_name: str) -> int:
681
+ """Count current nodes of a given type in the live graph."""
682
+ try:
683
+ return sum(1 for n in self.graph.nodes.values() if getattr(n, "type", None) == type_name)
684
+ except Exception:
685
+ return 0
686
+
687
+ def _can_add_node_of_type(self, type_name: str) -> bool:
688
+ """Check whether adding another node of given type is allowed by max_num."""
689
+ limit = self._type_limit(type_name)
690
+ if limit is None:
691
+ return True
692
+ return self._count_nodes_of_type(type_name) < int(limit)
693
+
671
694
  def _on_scene_context_menu(self, scene_pos: QPointF):
672
695
  """Show context menu for adding nodes and undo/redo/clear at empty scene position."""
673
696
  try:
@@ -685,7 +708,21 @@ class NodeEditor(QWidget):
685
708
  add_menu = menu.addMenu(self.config.menu_add())
686
709
  action_by_type: Dict[QAction, str] = {}
687
710
  for tname in self.graph.registry.types():
688
- act = add_menu.addAction(tname)
711
+ # Prefer UI-only display name; fallback to identifier (type_name)
712
+ try:
713
+ label = self.graph.registry.display_name(tname)
714
+ except Exception:
715
+ label = tname
716
+ act = add_menu.addAction(label)
717
+ # Show the underlying identifier in tooltip to make UI/ID relation explicit
718
+ if label != tname:
719
+ try:
720
+ act.setToolTip(tname)
721
+ except Exception:
722
+ pass
723
+ # Disable when limit reached (evaluated in real-time, per layout)
724
+ if not self._can_add_node_of_type(tname):
725
+ act.setEnabled(False)
689
726
  action_by_type[act] = tname
690
727
 
691
728
  menu.addSeparator()
@@ -711,7 +748,8 @@ class NodeEditor(QWidget):
711
748
  self.clear(ask_user=True)
712
749
  elif chosen in action_by_type:
713
750
  type_name = action_by_type[chosen]
714
- self._undo.push(AddNodeCommand(self, type_name, scene_pos))
751
+ # Route through editor.add_node() to enforce limit guards
752
+ self.add_node(type_name, scene_pos)
715
753
 
716
754
  # ---------- Z-order helpers ----------
717
755