pygpt-net 2.6.59__py3-none-any.whl → 2.6.61__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 (91) hide show
  1. pygpt_net/CHANGELOG.txt +11 -0
  2. pygpt_net/__init__.py +3 -3
  3. pygpt_net/app.py +9 -5
  4. pygpt_net/controller/__init__.py +1 -0
  5. pygpt_net/controller/chat/common.py +115 -6
  6. pygpt_net/controller/chat/input.py +4 -1
  7. pygpt_net/controller/presets/editor.py +442 -39
  8. pygpt_net/controller/presets/presets.py +121 -6
  9. pygpt_net/controller/settings/editor.py +0 -15
  10. pygpt_net/controller/theme/markdown.py +2 -5
  11. pygpt_net/controller/ui/ui.py +4 -7
  12. pygpt_net/core/agents/custom/__init__.py +281 -0
  13. pygpt_net/core/agents/custom/debug.py +64 -0
  14. pygpt_net/core/agents/custom/factory.py +109 -0
  15. pygpt_net/core/agents/custom/graph.py +71 -0
  16. pygpt_net/core/agents/custom/llama_index/__init__.py +10 -0
  17. pygpt_net/core/agents/custom/llama_index/factory.py +100 -0
  18. pygpt_net/core/agents/custom/llama_index/router_streamer.py +106 -0
  19. pygpt_net/core/agents/custom/llama_index/runner.py +562 -0
  20. pygpt_net/core/agents/custom/llama_index/stream.py +56 -0
  21. pygpt_net/core/agents/custom/llama_index/utils.py +253 -0
  22. pygpt_net/core/agents/custom/logging.py +50 -0
  23. pygpt_net/core/agents/custom/memory.py +51 -0
  24. pygpt_net/core/agents/custom/router.py +155 -0
  25. pygpt_net/core/agents/custom/router_streamer.py +187 -0
  26. pygpt_net/core/agents/custom/runner.py +455 -0
  27. pygpt_net/core/agents/custom/schema.py +127 -0
  28. pygpt_net/core/agents/custom/utils.py +193 -0
  29. pygpt_net/core/agents/provider.py +72 -7
  30. pygpt_net/core/agents/runner.py +7 -4
  31. pygpt_net/core/agents/runners/helpers.py +1 -1
  32. pygpt_net/core/agents/runners/llama_workflow.py +3 -0
  33. pygpt_net/core/agents/runners/openai_workflow.py +8 -1
  34. pygpt_net/core/db/viewer.py +11 -5
  35. pygpt_net/{ui/widget/builder → core/node_editor}/__init__.py +2 -2
  36. pygpt_net/core/{builder → node_editor}/graph.py +28 -226
  37. pygpt_net/core/node_editor/models.py +118 -0
  38. pygpt_net/core/node_editor/types.py +78 -0
  39. pygpt_net/core/node_editor/utils.py +17 -0
  40. pygpt_net/core/presets/presets.py +216 -29
  41. pygpt_net/core/render/markdown/parser.py +0 -2
  42. pygpt_net/core/render/web/renderer.py +10 -8
  43. pygpt_net/data/config/config.json +5 -6
  44. pygpt_net/data/config/models.json +3 -3
  45. pygpt_net/data/config/settings.json +2 -38
  46. pygpt_net/data/locale/locale.de.ini +64 -1
  47. pygpt_net/data/locale/locale.en.ini +63 -4
  48. pygpt_net/data/locale/locale.es.ini +64 -1
  49. pygpt_net/data/locale/locale.fr.ini +64 -1
  50. pygpt_net/data/locale/locale.it.ini +64 -1
  51. pygpt_net/data/locale/locale.pl.ini +65 -2
  52. pygpt_net/data/locale/locale.uk.ini +64 -1
  53. pygpt_net/data/locale/locale.zh.ini +64 -1
  54. pygpt_net/data/locale/plugin.cmd_system.en.ini +62 -66
  55. pygpt_net/item/agent.py +5 -1
  56. pygpt_net/item/preset.py +19 -1
  57. pygpt_net/provider/agents/base.py +33 -2
  58. pygpt_net/provider/agents/llama_index/flow_from_schema.py +92 -0
  59. pygpt_net/provider/agents/openai/flow_from_schema.py +96 -0
  60. pygpt_net/provider/core/agent/json_file.py +11 -5
  61. pygpt_net/provider/core/config/patch.py +10 -1
  62. pygpt_net/provider/core/config/patches/patch_before_2_6_42.py +0 -6
  63. pygpt_net/tools/agent_builder/tool.py +233 -52
  64. pygpt_net/tools/agent_builder/ui/dialogs.py +172 -28
  65. pygpt_net/tools/agent_builder/ui/list.py +37 -10
  66. pygpt_net/ui/__init__.py +2 -4
  67. pygpt_net/ui/dialog/about.py +58 -38
  68. pygpt_net/ui/dialog/db.py +142 -3
  69. pygpt_net/ui/dialog/preset.py +62 -8
  70. pygpt_net/ui/layout/toolbox/presets.py +52 -16
  71. pygpt_net/ui/main.py +1 -1
  72. pygpt_net/ui/widget/dialog/db.py +0 -0
  73. pygpt_net/ui/widget/lists/preset.py +644 -60
  74. pygpt_net/{core/builder → ui/widget/node_editor}/__init__.py +2 -2
  75. pygpt_net/ui/widget/node_editor/command.py +373 -0
  76. pygpt_net/ui/widget/node_editor/config.py +157 -0
  77. pygpt_net/ui/widget/node_editor/editor.py +2070 -0
  78. pygpt_net/ui/widget/node_editor/item.py +493 -0
  79. pygpt_net/ui/widget/node_editor/node.py +1460 -0
  80. pygpt_net/ui/widget/node_editor/utils.py +17 -0
  81. pygpt_net/ui/widget/node_editor/view.py +364 -0
  82. pygpt_net/ui/widget/tabs/output.py +1 -1
  83. pygpt_net/ui/widget/textarea/input.py +2 -2
  84. pygpt_net/utils.py +114 -2
  85. {pygpt_net-2.6.59.dist-info → pygpt_net-2.6.61.dist-info}/METADATA +80 -93
  86. {pygpt_net-2.6.59.dist-info → pygpt_net-2.6.61.dist-info}/RECORD +88 -61
  87. pygpt_net/core/agents/custom.py +0 -150
  88. pygpt_net/ui/widget/builder/editor.py +0 -2001
  89. {pygpt_net-2.6.59.dist-info → pygpt_net-2.6.61.dist-info}/LICENSE +0 -0
  90. {pygpt_net-2.6.59.dist-info → pygpt_net-2.6.61.dist-info}/WHEEL +0 -0
  91. {pygpt_net-2.6.59.dist-info → pygpt_net-2.6.61.dist-info}/entry_points.txt +0 -0
@@ -0,0 +1,493 @@
1
+ #!/usr/bin/env python3
2
+ # -*- coding: utf-8 -*-
3
+ # ================================================== #
4
+ # This file is a part of PYGPT package #
5
+ # Website: https://pygpt.net #
6
+ # GitHub: https://github.com/szczyglis-dev/py-gpt #
7
+ # MIT License #
8
+ # Created By : Marcin Szczygliński #
9
+ # Updated Date: 2025.09.25 12:05:00 #
10
+ # ================================================== #
11
+
12
+ from __future__ import annotations
13
+ from typing import Optional, List, Tuple, Dict
14
+
15
+ from PySide6.QtCore import Qt, QPointF, QRectF, Signal
16
+ from PySide6.QtGui import QAction, QBrush, QColor, QPainter, QPainterPath, QPen, QPainterPathStroker, QFont
17
+ from PySide6.QtWidgets import (
18
+ QWidget, QGraphicsItem, QGraphicsPathItem, QGraphicsObject, QStyleOptionGraphicsItem, QMenu, QGraphicsSimpleTextItem
19
+ )
20
+
21
+ from .utils import _qt_is_valid
22
+
23
+
24
+ # ------------------------ Items: Port, Edge, Node ------------------------
25
+
26
+ class PortItem(QGraphicsObject):
27
+ """Circular port that can initiate and accept connections.
28
+
29
+ A port is associated with a node property (prop_id) and has a side:
30
+ - 'input' for inbound connections
31
+ - 'output' for outbound connections
32
+
33
+ The item also renders a small capacity label (how many connections are allowed),
34
+ based on the live node type specification from the registry.
35
+ """
36
+ radius = 6.0
37
+ portClicked = Signal(object) # self
38
+ side: str # "input" or "output"
39
+
40
+ def __init__(self, node_item: "NodeItem", prop_id: str, side: str):
41
+ """Create a new port item.
42
+
43
+ Args:
44
+ node_item: Parent NodeItem to which this port belongs.
45
+ prop_id: Property identifier this port represents.
46
+ side: 'input' or 'output'.
47
+ """
48
+ super().__init__(node_item)
49
+ self.node_item = node_item
50
+ self.prop_id = prop_id
51
+ self.side = side
52
+ self.setAcceptHoverEvents(True)
53
+ self.setAcceptedMouseButtons(Qt.LeftButton)
54
+ self._hover = False
55
+ self._connected_count = 0
56
+ self._can_accept = False
57
+ self.setZValue(3)
58
+
59
+ # Small label for capacity only (IN/OUT removed from UI)
60
+ self._label_io = QGraphicsSimpleTextItem(self) # kept for compatibility; hidden
61
+ self._label_cap = QGraphicsSimpleTextItem(self)
62
+
63
+ # Fonts for capacity label
64
+ self._font_small = QFont()
65
+ self._font_small.setPixelSize(9)
66
+ self._font_cap_num = QFont()
67
+ self._font_cap_num.setPixelSize(9)
68
+ self._font_cap_inf = QFont()
69
+ self._font_cap_inf.setPixelSize(14) # slightly bigger infinity sign for readability
70
+
71
+ self._label_io.setFont(self._font_small)
72
+ self._label_cap.setFont(self._font_cap_num)
73
+
74
+ self._update_label_texts()
75
+ self._update_label_colors()
76
+ self._update_label_positions()
77
+ self._update_tooltip() # initial tooltip
78
+
79
+ def _allowed_capacity(self) -> Optional[int]:
80
+ """Return allowed connection count for this port from live registry/spec."""
81
+ return self.node_item.allowed_capacity_for_pid(self.prop_id, self.side)
82
+
83
+ def _update_label_texts(self):
84
+ """Update label texts to reflect current capacity for this port."""
85
+ # Hide IN/OUT label completely
86
+ self._label_io.setText("")
87
+ self._label_io.setVisible(False)
88
+
89
+ # Capacity: show max allowed for this port side from live spec
90
+ cap_val = self._allowed_capacity()
91
+ text = ""
92
+ if isinstance(cap_val, int) and cap_val != 0:
93
+ if cap_val < 0:
94
+ text = "\u221E"
95
+ else:
96
+ text = str(cap_val)
97
+ self._label_cap.setText(text)
98
+ # Adjust capacity font: make infinity larger, keep numbers as-is
99
+ if text == "\u221E":
100
+ self._label_cap.setFont(self._font_cap_inf)
101
+ else:
102
+ self._label_cap.setFont(self._font_cap_num)
103
+
104
+ def _update_label_colors(self):
105
+ """Apply theme colors to the labels."""
106
+ editor = self.node_item.editor
107
+ self._label_io.setBrush(QBrush(editor._port_label_color))
108
+ self._label_cap.setBrush(QBrush(editor._port_capacity_color))
109
+
110
+ def _update_label_positions(self):
111
+ """Position capacity label around the port, respecting the port side."""
112
+ r = self.radius
113
+ cap_rect = self._label_cap.boundingRect()
114
+ gap = 3.0
115
+
116
+ # Move capacity label up by additional ~3px (total ~7px up from original baseline)
117
+ dy_cap = -6.0
118
+
119
+ if self.side == "input":
120
+ if self._label_cap.text():
121
+ cap_x = -r - gap - cap_rect.width()
122
+ self._label_cap.setPos(cap_x, -cap_rect.height() / 2.0 + dy_cap)
123
+ else:
124
+ self._label_cap.setPos(-r - gap, -cap_rect.height() / 2.0 + dy_cap)
125
+ else:
126
+ if self._label_cap.text():
127
+ cap_x = r + gap
128
+ self._label_cap.setPos(cap_x, -cap_rect.height() / 2.0 + dy_cap)
129
+ else:
130
+ self._label_cap.setPos(r + gap, -cap_rect.height() / 2.0 + dy_cap)
131
+
132
+ def _update_tooltip(self):
133
+ """Build and apply a helpful tooltip for this port.
134
+
135
+ Shows node name, port side/id, and allowed connections, with interaction hints.
136
+ """
137
+ node_name = ""
138
+ try:
139
+ node_name = self.node_item.node.name
140
+ except Exception:
141
+ pass
142
+ cap_val = self._allowed_capacity()
143
+ cfg = self.node_item.editor.config
144
+ if isinstance(cap_val, int):
145
+ if cap_val < 0:
146
+ cap_str = cfg.cap_unlimited()
147
+ else:
148
+ cap_str = str(cap_val)
149
+ else:
150
+ cap_str = cfg.cap_na()
151
+ side_label = cfg.side_label(self.side).upper()
152
+ tip = cfg.port_tooltip(node_name, side_label, self.prop_id, cap_str)
153
+ self.setToolTip(tip)
154
+ try:
155
+ self._label_cap.setToolTip(tip)
156
+ except Exception:
157
+ pass
158
+
159
+ def notify_theme_changed(self):
160
+ """Refresh colors when global theme changes."""
161
+ self._update_label_colors()
162
+ self.update()
163
+
164
+ def boundingRect(self) -> QRectF:
165
+ """Compute a bounding rect large enough for the port and its label."""
166
+ r = self.radius
167
+ cap_rect = self._label_cap.boundingRect()
168
+ gap = 3.0
169
+ cap_text = self._label_cap.text()
170
+
171
+ # Allocate space only for the capacity label (IN/OUT removed)
172
+ left_extra = 6.0
173
+ right_extra = 6.0
174
+ if self.side == "input" and cap_text:
175
+ left_extra = gap + cap_rect.width() + 4.0
176
+ if self.side == "output" and cap_text:
177
+ right_extra = gap + cap_rect.width() + 4.0
178
+
179
+ # Add a bit more vertical padding to fully cover the label lifted upwards
180
+ h = max(2 * r, cap_rect.height()) + 12.0
181
+ w = 2 * r + left_extra + right_extra
182
+ return QRectF(-r - left_extra, -h / 2.0, w, h)
183
+
184
+ def shape(self) -> QPainterPath:
185
+ """Use a larger 'pick' radius for easier mouse interaction."""
186
+ pick_r = float(getattr(self.node_item.editor, "_port_pick_radius", 10.0)) or 10.0
187
+ p = QPainterPath()
188
+ p.addEllipse(QRectF(-pick_r, -pick_r, 2 * pick_r, 2 * pick_r))
189
+ return p
190
+
191
+ def paint(self, p: QPainter, opt: QStyleOptionGraphicsItem, widget=None):
192
+ """Render the port as a filled circle with an optional highlight ring."""
193
+ editor = self.node_item.editor
194
+ base = editor._port_input_color if self.side == "input" else editor._port_output_color
195
+ color = editor._port_connected_color if self._connected_count > 0 else base
196
+ if self._hover:
197
+ color = color.lighter(130)
198
+ p.setRenderHint(QPainter.Antialiasing, True)
199
+ p.setPen(Qt.NoPen)
200
+ p.setBrush(QBrush(color))
201
+ p.drawEllipse(QRectF(-self.radius, -self.radius, 2 * self.radius, 2 * self.radius))
202
+ if self._can_accept:
203
+ ring = QPen(editor._port_accept_color, 3.0)
204
+ ring.setCosmetic(True)
205
+ p.setPen(ring)
206
+ p.setBrush(Qt.NoBrush)
207
+ p.drawEllipse(QRectF(-self.radius, -self.radius, 2 * self.radius, 2 * self.radius))
208
+
209
+ def hoverEnterEvent(self, e):
210
+ """Enable hover flag and repaint."""
211
+ self._hover = True
212
+ self.update()
213
+ super().hoverEnterEvent(e)
214
+
215
+ def hoverLeaveEvent(self, e):
216
+ """Disable hover flag and repaint."""
217
+ self._hover = False
218
+ self.update()
219
+ super().hoverLeaveEvent(e)
220
+
221
+ def mousePressEvent(self, e):
222
+ """Emit portClicked on left click to begin a connection or rewire."""
223
+ if e.button() == Qt.LeftButton:
224
+ try:
225
+ # Bring parent node to front when clicking the port
226
+ self.node_item.editor.raise_node_to_top(self.node_item)
227
+ except Exception:
228
+ pass
229
+ self.node_item.editor._dbg(f"Port clicked: side={self.side}, node={self.node_item.node.name}({self.node_item.node.uuid}), prop={self.prop_id}, connected_count={self._connected_count}")
230
+ self.portClicked.emit(self)
231
+ e.accept()
232
+ return
233
+ super().mousePressEvent(e)
234
+
235
+ def increment_connections(self, delta: int):
236
+ """Increment or decrement the connected edges counter and repaint.
237
+
238
+ Args:
239
+ delta: Positive to increase, negative to decrease. Result is clamped to >= 0.
240
+ """
241
+ self._connected_count = max(0, self._connected_count + delta)
242
+ self.update()
243
+
244
+ def set_accept_highlight(self, enabled: bool):
245
+ """Toggle a visible 'can accept connection' ring."""
246
+ if self._can_accept != enabled:
247
+ self._can_accept = enabled
248
+ self.update()
249
+
250
+ def update_labels(self):
251
+ """Rebuild label text/position and tooltip (e.g., after spec/theme change)."""
252
+ self._update_label_texts()
253
+ self._update_label_positions()
254
+ self._update_tooltip()
255
+ self.update()
256
+
257
+
258
+ class EdgeItem(QGraphicsPathItem):
259
+ """Cubic bezier edge connecting two PortItem endpoints.
260
+
261
+ The item can be temporary (during interaction) or persistent (synced with the model).
262
+ It supports selection, hover highlight and a context menu for deletion.
263
+ """
264
+ def __init__(self, src_port: PortItem, dst_port: PortItem, temporary: bool = False):
265
+ """Initialize an edge between src_port and dst_port.
266
+
267
+ Args:
268
+ src_port: Output side port.
269
+ dst_port: Input side port (or same as src during interactive drag).
270
+ temporary: When True, draws with dashed pen and does not expose context actions.
271
+ """
272
+ super().__init__()
273
+ self.src_port = src_port
274
+ self.dst_port = dst_port
275
+ self.temporary = temporary
276
+ self.setZValue(1)
277
+ self.setFlag(QGraphicsItem.ItemIsSelectable, True)
278
+ self.setAcceptedMouseButtons(Qt.LeftButton | Qt.RightButton)
279
+ self._editor = self.src_port.node_item.editor
280
+ self._hover = False
281
+ self._drag_primed = False
282
+ self._drag_start_scene = QPointF()
283
+ self._update_pen()
284
+
285
+ def set_hovered(self, hovered: bool):
286
+ """Set hover state and refresh pen color."""
287
+ if self._hover != hovered:
288
+ self._hover = hovered
289
+ self._update_pen()
290
+
291
+ def _update_pen(self):
292
+ """Update the pen based on hover/selection/temporary state."""
293
+ color = self._editor._edge_selected_color if (self._hover or self.isSelected()) else self._editor._edge_color
294
+ pen = QPen(color)
295
+ pen.setWidthF(2.0 if not self.temporary else 1.5)
296
+ pen.setStyle(Qt.SolidLine if not self.temporary else Qt.DashLine)
297
+ pen.setCosmetic(True)
298
+ self.setPen(pen)
299
+ self.update()
300
+
301
+ def itemChange(self, change, value):
302
+ """Refresh pen when selection state changes."""
303
+ if change == QGraphicsItem.ItemSelectedHasChanged:
304
+ self._update_pen()
305
+ return super().itemChange(change, value)
306
+
307
+ def shape(self) -> QPainterPath:
308
+ """Return an inflated path for comfortable picking/selection."""
309
+ stroker = QPainterPathStroker()
310
+ width = float(getattr(self._editor, "_edge_pick_width", 12.0) or 12.0)
311
+ stroker.setWidth(width)
312
+ stroker.setCapStyle(Qt.RoundCap)
313
+ stroker.setJoinStyle(Qt.RoundJoin)
314
+ return stroker.createStroke(self.path())
315
+
316
+ def update_path(self, end_pos: Optional[QPointF] = None):
317
+ """Recompute the cubic path between endpoints.
318
+
319
+ Args:
320
+ end_pos: Optional scene position for the free endpoint while dragging.
321
+ When None, uses dst_port scenePos().
322
+
323
+ Notes:
324
+ Control points are placed horizontally at 50% of the delta X for a classic bezier look.
325
+ """
326
+ try:
327
+ sp = getattr(self, "src_port", None)
328
+ dp = getattr(self, "dst_port", None)
329
+ if not _qt_is_valid(sp):
330
+ return
331
+ p0 = sp.scenePos()
332
+ if end_pos is not None:
333
+ p1 = end_pos
334
+ else:
335
+ if not _qt_is_valid(dp):
336
+ return
337
+ p1 = dp.scenePos()
338
+
339
+ dx = abs(p1.x() - p0.x())
340
+ c1 = QPointF(p0.x() + dx * 0.5, p0.y())
341
+ c2 = QPointF(p1.x() - dx * 0.5, p1.y())
342
+ p = QPainterPath()
343
+ p.moveTo(p0)
344
+ p.cubicTo(c1, c2, p1)
345
+ self.setPath(p)
346
+ except Exception:
347
+ # Fail-safe during GC/teardown
348
+ return
349
+
350
+ def contextMenuEvent(self, event):
351
+ """Show a context menu to delete this connection (only for persistent edges)."""
352
+ if self.temporary:
353
+ return
354
+ self._editor._dbg(f"Edge context menu on edge id={id(self)}")
355
+ menu = QMenu(self._editor.window())
356
+ ss = self._editor.window().styleSheet()
357
+ if ss:
358
+ menu.setStyleSheet(ss)
359
+ act_del = QAction(self._editor.config.edge_context_delete(), menu)
360
+ menu.addAction(act_del)
361
+ chosen = menu.exec(event.screenPos())
362
+ if chosen == act_del:
363
+ self._editor._dbg(f"Context DELETE on edge id={id(self)} (undoable)")
364
+ self._editor._delete_edge_undoable(self)
365
+
366
+ def mousePressEvent(self, e):
367
+ """Prime dragging on left-click, enabling rewire from edge."""
368
+ if not self.temporary and e.button() == Qt.LeftButton:
369
+ if not self.isSelected():
370
+ self.setSelected(True)
371
+ self._drag_primed = True
372
+ self._drag_start_scene = e.scenePos()
373
+ self._editor._dbg(f"Edge LMB press -> primed drag, edge id={id(self)}")
374
+ e.accept()
375
+ return
376
+ super().mousePressEvent(e)
377
+
378
+ def mouseMoveEvent(self, e):
379
+ """When drag threshold is exceeded, start interactive rewire from edge."""
380
+ if not self.temporary and self._drag_primed:
381
+ dist = abs(e.scenePos().x() - self._drag_start_scene.x()) + abs(e.scenePos().y() - self._drag_start_scene.y())
382
+ if dist > 6 and getattr(self._editor, "_wire_state", "idle") == "idle":
383
+ self._editor._dbg(f"Edge drag start -> begin REWIRE from EDGE (move dst), edge id={id(self)}")
384
+ self._editor._start_rewire_from_edge(self, e.scenePos())
385
+ self._drag_primed = False
386
+ e.accept()
387
+ return
388
+ super().mouseMoveEvent(e)
389
+
390
+ def mouseReleaseEvent(self, e):
391
+ """Reset the primed state on release."""
392
+ self._drag_primed = False
393
+ super().mouseReleaseEvent(e)
394
+
395
+
396
+ class NodeOverlayItem(QGraphicsItem):
397
+ """Lightweight overlay drawn above the proxy widget to add subtle guides.
398
+
399
+ The overlay inspects the positions of property editors inside NodeContentWidget and
400
+ draws separators between rows to improve readability.
401
+ """
402
+ def __init__(self, node_item: "NodeItem"):
403
+ """Create an overlay attached to the given NodeItem."""
404
+ super().__init__(node_item)
405
+ self.node_item = node_item
406
+ self.setAcceptedMouseButtons(Qt.NoButton)
407
+ self.setAcceptHoverEvents(False)
408
+ self.setZValue(2.1)
409
+ # Track which property row is hovered to paint a subtle background
410
+ self._hover_pid: Optional[str] = None
411
+
412
+ def boundingRect(self) -> QRectF:
413
+ """Compute the overlay rect (content area below the title)."""
414
+ ni = self.node_item
415
+ hit = ni._effective_hit_margin()
416
+ return QRectF(0.0, float(ni._title_height),
417
+ max(0.0, ni.size().width() - hit),
418
+ max(0.0, ni.size().height() - ni._title_height - hit))
419
+
420
+ def set_hover_pid(self, pid: Optional[str]):
421
+ """Set hovered property id to be highlighted."""
422
+ if self._hover_pid != pid:
423
+ self._hover_pid = pid
424
+ self.update()
425
+
426
+ def paint(self, p: QPainter, opt: QStyleOptionGraphicsItem, widget=None):
427
+ """Draw full-width separators and a hover background that snaps to separators (no vertical gaps)."""
428
+ ni = self.node_item
429
+ if not _qt_is_valid(ni):
430
+ return
431
+ p.setRenderHint(QPainter.Antialiasing, True)
432
+
433
+ base = QColor(ni.editor._node_border_color)
434
+ br = self.boundingRect()
435
+ left = br.left() # full width
436
+ right = br.right() # full width
437
+
438
+ try:
439
+ # Collect editor widgets -> rows and keep property order
440
+ editors: List[Tuple[str, QWidget]] = []
441
+ for pid in ni.node.properties.keys():
442
+ w = ni._content._editors.get(pid)
443
+ if isinstance(w, QWidget) and _qt_is_valid(w):
444
+ editors.append((pid, w))
445
+ if not editors:
446
+ return
447
+
448
+ proxy_off = ni._proxy.pos()
449
+ rows: List[Tuple[str, float, float]] = []
450
+ for pid, w in editors:
451
+ geo = w.geometry()
452
+ top = float(proxy_off.y()) + float(geo.y())
453
+ bottom = top + float(geo.height())
454
+ rows.append((pid, top, bottom))
455
+
456
+ # Compute separator lines at mid-gap between consecutive rows (clamped to overlay bounds)
457
+ seps: List[float] = []
458
+ for i in range(len(rows) - 1):
459
+ _, _, bottom_a = rows[i]
460
+ _, top_b, _ = rows[i + 1]
461
+ y = (bottom_a + top_b) * 0.5
462
+ if br.top() <= y <= br.bottom():
463
+ seps.append(y)
464
+
465
+ # Build vertical zones per row: from previous separator to next separator (or overlay edges)
466
+ zones: Dict[str, Tuple[float, float]] = {}
467
+ for i, (pid, _top, _bottom) in enumerate(rows):
468
+ z_top = br.top() if i == 0 else (seps[i - 1] if (i - 1) < len(seps) else br.top())
469
+ z_bot = br.bottom() if i == len(rows) - 1 else (seps[i] if i < len(seps) else br.bottom())
470
+ # Ensure within overlay bounds
471
+ z_top = max(br.top(), min(z_top, br.bottom()))
472
+ z_bot = max(br.top(), min(z_bot, br.bottom()))
473
+ if z_bot > z_top:
474
+ zones[pid] = (z_top, z_bot)
475
+
476
+ # 1) Hover background: full width, vertically up to separators (no gap)
477
+ if self._hover_pid and self._hover_pid in zones:
478
+ y1, y2 = zones[self._hover_pid]
479
+ hl = QColor(base.lighter(170))
480
+ hl.setAlpha(25) # subtle
481
+ p.fillRect(QRectF(left, y1, right - left, y2 - y1), hl)
482
+
483
+ # 2) Full-width separators
484
+ sep = QColor(base.lighter(160))
485
+ sep.setAlpha(130)
486
+ pen_sep = QPen(sep, 1.25, Qt.SolidLine)
487
+ pen_sep.setCosmetic(True)
488
+ p.setPen(pen_sep)
489
+ for y in seps:
490
+ if br.top() + 0.5 <= y <= br.bottom() - 0.5:
491
+ p.drawLine(QPointF(left, y), QPointF(right, y))
492
+ except Exception:
493
+ pass