pygpt-net 2.6.59__py3-none-any.whl → 2.6.60__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 (61) hide show
  1. pygpt_net/CHANGELOG.txt +4 -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/presets/editor.py +442 -39
  6. pygpt_net/core/agents/custom/__init__.py +275 -0
  7. pygpt_net/core/agents/custom/debug.py +64 -0
  8. pygpt_net/core/agents/custom/factory.py +109 -0
  9. pygpt_net/core/agents/custom/graph.py +71 -0
  10. pygpt_net/core/agents/custom/llama_index/__init__.py +10 -0
  11. pygpt_net/core/agents/custom/llama_index/factory.py +89 -0
  12. pygpt_net/core/agents/custom/llama_index/router_streamer.py +106 -0
  13. pygpt_net/core/agents/custom/llama_index/runner.py +529 -0
  14. pygpt_net/core/agents/custom/llama_index/stream.py +56 -0
  15. pygpt_net/core/agents/custom/llama_index/utils.py +242 -0
  16. pygpt_net/core/agents/custom/logging.py +50 -0
  17. pygpt_net/core/agents/custom/memory.py +51 -0
  18. pygpt_net/core/agents/custom/router.py +116 -0
  19. pygpt_net/core/agents/custom/router_streamer.py +187 -0
  20. pygpt_net/core/agents/custom/runner.py +454 -0
  21. pygpt_net/core/agents/custom/schema.py +125 -0
  22. pygpt_net/core/agents/custom/utils.py +181 -0
  23. pygpt_net/core/agents/provider.py +72 -7
  24. pygpt_net/core/agents/runner.py +7 -4
  25. pygpt_net/core/agents/runners/helpers.py +1 -1
  26. pygpt_net/core/agents/runners/llama_workflow.py +3 -0
  27. pygpt_net/core/agents/runners/openai_workflow.py +8 -1
  28. pygpt_net/{ui/widget/builder → core/node_editor}/__init__.py +2 -2
  29. pygpt_net/core/{builder → node_editor}/graph.py +11 -218
  30. pygpt_net/core/node_editor/models.py +111 -0
  31. pygpt_net/core/node_editor/types.py +76 -0
  32. pygpt_net/core/node_editor/utils.py +17 -0
  33. pygpt_net/core/render/web/renderer.py +10 -8
  34. pygpt_net/data/config/config.json +3 -3
  35. pygpt_net/data/config/models.json +3 -3
  36. pygpt_net/data/locale/locale.en.ini +4 -4
  37. pygpt_net/item/agent.py +5 -1
  38. pygpt_net/item/preset.py +19 -1
  39. pygpt_net/provider/agents/base.py +33 -2
  40. pygpt_net/provider/agents/llama_index/flow_from_schema.py +92 -0
  41. pygpt_net/provider/agents/openai/flow_from_schema.py +96 -0
  42. pygpt_net/provider/core/agent/json_file.py +11 -5
  43. pygpt_net/tools/agent_builder/tool.py +217 -52
  44. pygpt_net/tools/agent_builder/ui/dialogs.py +119 -24
  45. pygpt_net/tools/agent_builder/ui/list.py +37 -10
  46. pygpt_net/ui/dialog/preset.py +16 -1
  47. pygpt_net/ui/main.py +1 -1
  48. pygpt_net/{core/builder → ui/widget/node_editor}/__init__.py +2 -2
  49. pygpt_net/ui/widget/node_editor/command.py +373 -0
  50. pygpt_net/ui/widget/node_editor/editor.py +2038 -0
  51. pygpt_net/ui/widget/node_editor/item.py +492 -0
  52. pygpt_net/ui/widget/node_editor/node.py +1205 -0
  53. pygpt_net/ui/widget/node_editor/utils.py +17 -0
  54. pygpt_net/ui/widget/node_editor/view.py +247 -0
  55. {pygpt_net-2.6.59.dist-info → pygpt_net-2.6.60.dist-info}/METADATA +72 -2
  56. {pygpt_net-2.6.59.dist-info → pygpt_net-2.6.60.dist-info}/RECORD +59 -33
  57. pygpt_net/core/agents/custom.py +0 -150
  58. pygpt_net/ui/widget/builder/editor.py +0 -2001
  59. {pygpt_net-2.6.59.dist-info → pygpt_net-2.6.60.dist-info}/LICENSE +0 -0
  60. {pygpt_net-2.6.59.dist-info → pygpt_net-2.6.60.dist-info}/WHEEL +0 -0
  61. {pygpt_net-2.6.59.dist-info → pygpt_net-2.6.60.dist-info}/entry_points.txt +0 -0
@@ -0,0 +1,492 @@
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.24 00:00: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
+ if isinstance(cap_val, int):
144
+ if cap_val < 0:
145
+ cap_str = "unlimited (∞)"
146
+ else:
147
+ cap_str = str(cap_val)
148
+ else:
149
+ cap_str = "n/a"
150
+ tip = (
151
+ f"Node: {node_name}\n"
152
+ f"Port: {self.side.upper()} • {self.prop_id}\n"
153
+ f"Allowed connections: {cap_str}\n\n"
154
+ f"Click: start a new connection\n"
155
+ f"Ctrl+Click: rewire/detach existing"
156
+ )
157
+ self.setToolTip(tip)
158
+ try:
159
+ self._label_cap.setToolTip(tip)
160
+ except Exception:
161
+ pass
162
+
163
+ def notify_theme_changed(self):
164
+ """Refresh colors when global theme changes."""
165
+ self._update_label_colors()
166
+ self.update()
167
+
168
+ def boundingRect(self) -> QRectF:
169
+ """Compute a bounding rect large enough for the port and its label."""
170
+ r = self.radius
171
+ cap_rect = self._label_cap.boundingRect()
172
+ gap = 3.0
173
+ cap_text = self._label_cap.text()
174
+
175
+ # Allocate space only for the capacity label (IN/OUT removed)
176
+ left_extra = 6.0
177
+ right_extra = 6.0
178
+ if self.side == "input" and cap_text:
179
+ left_extra = gap + cap_rect.width() + 4.0
180
+ if self.side == "output" and cap_text:
181
+ right_extra = gap + cap_rect.width() + 4.0
182
+
183
+ # Add a bit more vertical padding to fully cover the label lifted upwards
184
+ h = max(2 * r, cap_rect.height()) + 12.0
185
+ w = 2 * r + left_extra + right_extra
186
+ return QRectF(-r - left_extra, -h / 2.0, w, h)
187
+
188
+ def shape(self) -> QPainterPath:
189
+ """Use a larger 'pick' radius for easier mouse interaction."""
190
+ pick_r = float(getattr(self.node_item.editor, "_port_pick_radius", 10.0)) or 10.0
191
+ p = QPainterPath()
192
+ p.addEllipse(QRectF(-pick_r, -pick_r, 2 * pick_r, 2 * pick_r))
193
+ return p
194
+
195
+ def paint(self, p: QPainter, opt: QStyleOptionGraphicsItem, widget=None):
196
+ """Render the port as a filled circle with an optional highlight ring."""
197
+ editor = self.node_item.editor
198
+ base = editor._port_input_color if self.side == "input" else editor._port_output_color
199
+ color = editor._port_connected_color if self._connected_count > 0 else base
200
+ if self._hover:
201
+ color = color.lighter(130)
202
+ p.setRenderHint(QPainter.Antialiasing, True)
203
+ p.setPen(Qt.NoPen)
204
+ p.setBrush(QBrush(color))
205
+ p.drawEllipse(QRectF(-self.radius, -self.radius, 2 * self.radius, 2 * self.radius))
206
+ if self._can_accept:
207
+ ring = QPen(editor._port_accept_color, 3.0)
208
+ ring.setCosmetic(True)
209
+ p.setPen(ring)
210
+ p.setBrush(Qt.NoBrush)
211
+ p.drawEllipse(QRectF(-self.radius, -self.radius, 2 * self.radius, 2 * self.radius))
212
+
213
+ def hoverEnterEvent(self, e):
214
+ """Enable hover flag and repaint."""
215
+ self._hover = True
216
+ self.update()
217
+ super().hoverEnterEvent(e)
218
+
219
+ def hoverLeaveEvent(self, e):
220
+ """Disable hover flag and repaint."""
221
+ self._hover = False
222
+ self.update()
223
+ super().hoverLeaveEvent(e)
224
+
225
+ def mousePressEvent(self, e):
226
+ """Emit portClicked on left click to begin a connection or rewire."""
227
+ if e.button() == Qt.LeftButton:
228
+ 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}")
229
+ self.portClicked.emit(self)
230
+ e.accept()
231
+ return
232
+ super().mousePressEvent(e)
233
+
234
+ def increment_connections(self, delta: int):
235
+ """Increment or decrement the connected edges counter and repaint.
236
+
237
+ Args:
238
+ delta: Positive to increase, negative to decrease. Result is clamped to >= 0.
239
+ """
240
+ self._connected_count = max(0, self._connected_count + delta)
241
+ self.update()
242
+
243
+ def set_accept_highlight(self, enabled: bool):
244
+ """Toggle a visible 'can accept connection' ring."""
245
+ if self._can_accept != enabled:
246
+ self._can_accept = enabled
247
+ self.update()
248
+
249
+ def update_labels(self):
250
+ """Rebuild label text/position and tooltip (e.g., after spec/theme change)."""
251
+ self._update_label_texts()
252
+ self._update_label_positions()
253
+ self._update_tooltip()
254
+ self.update()
255
+
256
+
257
+ class EdgeItem(QGraphicsPathItem):
258
+ """Cubic bezier edge connecting two PortItem endpoints.
259
+
260
+ The item can be temporary (during interaction) or persistent (synced with the model).
261
+ It supports selection, hover highlight and a context menu for deletion.
262
+ """
263
+ def __init__(self, src_port: PortItem, dst_port: PortItem, temporary: bool = False):
264
+ """Initialize an edge between src_port and dst_port.
265
+
266
+ Args:
267
+ src_port: Output side port.
268
+ dst_port: Input side port (or same as src during interactive drag).
269
+ temporary: When True, draws with dashed pen and does not expose context actions.
270
+ """
271
+ super().__init__()
272
+ self.src_port = src_port
273
+ self.dst_port = dst_port
274
+ self.temporary = temporary
275
+ self.setZValue(1)
276
+ self.setFlag(QGraphicsItem.ItemIsSelectable, True)
277
+ self.setAcceptedMouseButtons(Qt.LeftButton | Qt.RightButton)
278
+ self._editor = self.src_port.node_item.editor
279
+ self._hover = False
280
+ self._drag_primed = False
281
+ self._drag_start_scene = QPointF()
282
+ self._update_pen()
283
+
284
+ def set_hovered(self, hovered: bool):
285
+ """Set hover state and refresh pen color."""
286
+ if self._hover != hovered:
287
+ self._hover = hovered
288
+ self._update_pen()
289
+
290
+ def _update_pen(self):
291
+ """Update the pen based on hover/selection/temporary state."""
292
+ color = self._editor._edge_selected_color if (self._hover or self.isSelected()) else self._editor._edge_color
293
+ pen = QPen(color)
294
+ pen.setWidthF(2.0 if not self.temporary else 1.5)
295
+ pen.setStyle(Qt.SolidLine if not self.temporary else Qt.DashLine)
296
+ pen.setCosmetic(True)
297
+ self.setPen(pen)
298
+ self.update()
299
+
300
+ def itemChange(self, change, value):
301
+ """Refresh pen when selection state changes."""
302
+ if change == QGraphicsItem.ItemSelectedHasChanged:
303
+ self._update_pen()
304
+ return super().itemChange(change, value)
305
+
306
+ def shape(self) -> QPainterPath:
307
+ """Return an inflated path for comfortable picking/selection."""
308
+ stroker = QPainterPathStroker()
309
+ width = float(getattr(self._editor, "_edge_pick_width", 12.0) or 12.0)
310
+ stroker.setWidth(width)
311
+ stroker.setCapStyle(Qt.RoundCap)
312
+ stroker.setJoinStyle(Qt.RoundJoin)
313
+ return stroker.createStroke(self.path())
314
+
315
+ def update_path(self, end_pos: Optional[QPointF] = None):
316
+ """Recompute the cubic path between endpoints.
317
+
318
+ Args:
319
+ end_pos: Optional scene position for the free endpoint while dragging.
320
+ When None, uses dst_port scenePos().
321
+
322
+ Notes:
323
+ Control points are placed horizontally at 50% of the delta X for a classic bezier look.
324
+ """
325
+ try:
326
+ sp = getattr(self, "src_port", None)
327
+ dp = getattr(self, "dst_port", None)
328
+ if not _qt_is_valid(sp):
329
+ return
330
+ p0 = sp.scenePos()
331
+ if end_pos is not None:
332
+ p1 = end_pos
333
+ else:
334
+ if not _qt_is_valid(dp):
335
+ return
336
+ p1 = dp.scenePos()
337
+
338
+ dx = abs(p1.x() - p0.x())
339
+ c1 = QPointF(p0.x() + dx * 0.5, p0.y())
340
+ c2 = QPointF(p1.x() - dx * 0.5, p1.y())
341
+ p = QPainterPath()
342
+ p.moveTo(p0)
343
+ p.cubicTo(c1, c2, p1)
344
+ self.setPath(p)
345
+ except Exception:
346
+ # Fail-safe during GC/teardown
347
+ return
348
+
349
+ def contextMenuEvent(self, event):
350
+ """Show a context menu to delete this connection (only for persistent edges)."""
351
+ if self.temporary:
352
+ return
353
+ self._editor._dbg(f"Edge context menu on edge id={id(self)}")
354
+ menu = QMenu(self._editor.window())
355
+ ss = self._editor.window().styleSheet()
356
+ if ss:
357
+ menu.setStyleSheet(ss)
358
+ act_del = QAction("Delete connection", menu)
359
+ menu.addAction(act_del)
360
+ chosen = menu.exec(event.screenPos())
361
+ if chosen == act_del:
362
+ self._editor._dbg(f"Context DELETE on edge id={id(self)} (undoable)")
363
+ self._editor._delete_edge_undoable(self)
364
+
365
+ def mousePressEvent(self, e):
366
+ """Prime dragging on left-click, enabling rewire from edge."""
367
+ if not self.temporary and e.button() == Qt.LeftButton:
368
+ if not self.isSelected():
369
+ self.setSelected(True)
370
+ self._drag_primed = True
371
+ self._drag_start_scene = e.scenePos()
372
+ self._editor._dbg(f"Edge LMB press -> primed drag, edge id={id(self)}")
373
+ e.accept()
374
+ return
375
+ super().mousePressEvent(e)
376
+
377
+ def mouseMoveEvent(self, e):
378
+ """When drag threshold is exceeded, start interactive rewire from edge."""
379
+ if not self.temporary and self._drag_primed:
380
+ dist = abs(e.scenePos().x() - self._drag_start_scene.x()) + abs(e.scenePos().y() - self._drag_start_scene.y())
381
+ if dist > 6 and getattr(self._editor, "_wire_state", "idle") == "idle":
382
+ self._editor._dbg(f"Edge drag start -> begin REWIRE from EDGE (move dst), edge id={id(self)}")
383
+ self._editor._start_rewire_from_edge(self, e.scenePos())
384
+ self._drag_primed = False
385
+ e.accept()
386
+ return
387
+ super().mouseMoveEvent(e)
388
+
389
+ def mouseReleaseEvent(self, e):
390
+ """Reset the primed state on release."""
391
+ self._drag_primed = False
392
+ super().mouseReleaseEvent(e)
393
+
394
+
395
+ class NodeOverlayItem(QGraphicsItem):
396
+ """Lightweight overlay drawn above the proxy widget to add subtle guides.
397
+
398
+ The overlay inspects the positions of property editors inside NodeContentWidget and
399
+ draws separators between rows to improve readability.
400
+ """
401
+ def __init__(self, node_item: "NodeItem"):
402
+ """Create an overlay attached to the given NodeItem."""
403
+ super().__init__(node_item)
404
+ self.node_item = node_item
405
+ self.setAcceptedMouseButtons(Qt.NoButton)
406
+ self.setAcceptHoverEvents(False)
407
+ self.setZValue(2.1)
408
+ # Track which property row is hovered to paint a subtle background
409
+ self._hover_pid: Optional[str] = None
410
+
411
+ def boundingRect(self) -> QRectF:
412
+ """Compute the overlay rect (content area below the title)."""
413
+ ni = self.node_item
414
+ hit = ni._effective_hit_margin()
415
+ return QRectF(0.0, float(ni._title_height),
416
+ max(0.0, ni.size().width() - hit),
417
+ max(0.0, ni.size().height() - ni._title_height - hit))
418
+
419
+ def set_hover_pid(self, pid: Optional[str]):
420
+ """Set hovered property id to be highlighted."""
421
+ if self._hover_pid != pid:
422
+ self._hover_pid = pid
423
+ self.update()
424
+
425
+ def paint(self, p: QPainter, opt: QStyleOptionGraphicsItem, widget=None):
426
+ """Draw full-width separators and a hover background that snaps to separators (no vertical gaps)."""
427
+ ni = self.node_item
428
+ if not _qt_is_valid(ni):
429
+ return
430
+ p.setRenderHint(QPainter.Antialiasing, True)
431
+
432
+ base = QColor(ni.editor._node_border_color)
433
+ br = self.boundingRect()
434
+ left = br.left() # full width
435
+ right = br.right() # full width
436
+
437
+ try:
438
+ # Collect editor widgets -> rows and keep property order
439
+ editors: List[Tuple[str, QWidget]] = []
440
+ for pid in ni.node.properties.keys():
441
+ w = ni._content._editors.get(pid)
442
+ if isinstance(w, QWidget) and _qt_is_valid(w):
443
+ editors.append((pid, w))
444
+ if not editors:
445
+ return
446
+
447
+ proxy_off = ni._proxy.pos()
448
+ rows: List[Tuple[str, float, float]] = []
449
+ for pid, w in editors:
450
+ geo = w.geometry()
451
+ top = float(proxy_off.y()) + float(geo.y())
452
+ bottom = top + float(geo.height())
453
+ rows.append((pid, top, bottom))
454
+
455
+ # Compute separator lines at mid-gap between consecutive rows (clamped to overlay bounds)
456
+ seps: List[float] = []
457
+ for i in range(len(rows) - 1):
458
+ _, _, bottom_a = rows[i]
459
+ _, top_b, _ = rows[i + 1]
460
+ y = (bottom_a + top_b) * 0.5
461
+ if br.top() <= y <= br.bottom():
462
+ seps.append(y)
463
+
464
+ # Build vertical zones per row: from previous separator to next separator (or overlay edges)
465
+ zones: Dict[str, Tuple[float, float]] = {}
466
+ for i, (pid, _top, _bottom) in enumerate(rows):
467
+ z_top = br.top() if i == 0 else (seps[i - 1] if (i - 1) < len(seps) else br.top())
468
+ z_bot = br.bottom() if i == len(rows) - 1 else (seps[i] if i < len(seps) else br.bottom())
469
+ # Ensure within overlay bounds
470
+ z_top = max(br.top(), min(z_top, br.bottom()))
471
+ z_bot = max(br.top(), min(z_bot, br.bottom()))
472
+ if z_bot > z_top:
473
+ zones[pid] = (z_top, z_bot)
474
+
475
+ # 1) Hover background: full width, vertically up to separators (no gap)
476
+ if self._hover_pid and self._hover_pid in zones:
477
+ y1, y2 = zones[self._hover_pid]
478
+ hl = QColor(base.lighter(170))
479
+ hl.setAlpha(25) # subtle
480
+ p.fillRect(QRectF(left, y1, right - left, y2 - y1), hl)
481
+
482
+ # 2) Full-width separators
483
+ sep = QColor(base.lighter(160))
484
+ sep.setAlpha(130)
485
+ pen_sep = QPen(sep, 1.25, Qt.SolidLine)
486
+ pen_sep.setCosmetic(True)
487
+ p.setPen(pen_sep)
488
+ for y in seps:
489
+ if br.top() + 0.5 <= y <= br.bottom() - 0.5:
490
+ p.drawLine(QPointF(left, y), QPointF(right, y))
491
+ except Exception:
492
+ pass