"""Các item vẽ trực tiếp trên canvas Co4E — dời khỏi ``ui/co4e_canvas.py``. Vấn đề đang có: gộp riêng ``Co4ECanvas`` (dòng 289-701 của file cũ) vào một file mới đã đủ 413 dòng, vượt trần 400 dòng/file production của CASAN Check 2 — dù không đổi gì bên trong. ``_NodeItem``/``_EdgeItem`` (và hằng số/hàm phụ trợ chúng dùng để vẽ) là phần độc lập nhất về mặt trách nhiệm: chỉ vẽ và xử lý sự kiện chuột NGAY TRÊN item đó, gọi ngược vào canvas cha qua tham số ``canvas`` được truyền ở constructor — nên tách được sang module riêng mà không cần đổi bất kỳ hành vi nào. Cách làm: cắt dán NGUYÊN VĂN các khối dòng 43-56 (hằng số + ``_status_color``) và 59-286 (``_NodeItem``, ``_EdgeItem``) từ ``ui/co4e_canvas.py`` sang đây, không đổi tên/tham số/thứ tự/giá trị mặc định — kể cả các quirk đã bị characterization test (``tests/characterization/test_co4e_canvas_widget.py``, ``test_co4e_canvas_geometry.py``) đóng đinh gián tiếp qua ``_rounded_path``/ ``_route``/``_elide`` mà ``_EdgeItem.update_path``/``_NodeItem.paint`` gọi. Tham số ``canvas: "Co4ECanvas"`` trong ``__init__`` của cả hai lớp dùng string forward-reference vì ``Co4ECanvas`` giờ nằm ở module ``co4e_canvas_widget.py`` khác — import trực tiếp sẽ tạo vòng lặp (canvas widget import ngược lại các item này). Đây thuần là type hint, không cần import runtime. ``ui/co4e_canvas.py`` import lại các tên public (``CO4E_MIME`` qua ``co4e_canvas_widget.py``) để giữ nguyên đường import mà test/characterization khác đang dùng. """ from __future__ import annotations from typing import Optional from PySide6.QtCore import QPointF, QRectF, Qt from PySide6.QtGui import QBrush, QColor, QPainterPath, QPen, QPolygonF from PySide6.QtWidgets import QGraphicsItem, QGraphicsObject, QGraphicsPathItem, QMenu from ...core.co4e import STEP_DONE, STEP_ERROR, STEP_PLANNED, STEP_RUNNING, Edge, Node from ...theme import current_palette from .canvas_geometry import _elide, _rounded_path def _status_color(status: str) -> str: """Accent colour for a step's run status. Resolved per paint so the canvas follows a live theme switch.""" p = current_palette() return { "idle": p.text_muted, STEP_RUNNING: p.accent, STEP_DONE: p.success, STEP_ERROR: p.danger, STEP_PLANNED: p.purple, "pending": p.text_faint, }.get(status, p.text_muted) CO4E_MIME = "application/x-co4e-step" _NODE_W, _NODE_H = 210, 96 _PORT_R = 6 # output port radius (the drag-to-connect handle) _PORT_HIT = 15 # click tolerance around a port class _NodeItem(QGraphicsObject): """One draggable step card. Emits signals via the parent canvas.""" def __init__(self, node: Node, canvas: "Co4ECanvas"): super().__init__() self.node = node self.canvas = canvas self.status = "idle" self._porting = False self.setFlags(QGraphicsItem.ItemIsMovable | QGraphicsItem.ItemIsSelectable | QGraphicsItem.ItemSendsGeometryChanges) self.setAcceptHoverEvents(True) self.setPos(node.x, node.y) self.setZValue(2) def boundingRect(self) -> QRectF: # slack left/right so the input/output ports (now on the sides) paint cleanly return QRectF(-_PORT_R - 2, -3, _NODE_W + 2 * _PORT_R + 4, _NODE_H + 6) def _card_rect(self) -> QRectF: return QRectF(1, 1, _NODE_W - 2, _NODE_H - 2) def paint(self, p, _opt, _widget=None): tok = current_palette() step = self.node.data accent = QColor(_status_color(self.status)) body = QColor(tok.surface_raised) border = QColor(tok.accent) if self.isSelected() else QColor(tok.border) p.setRenderHint(p.RenderHint.Antialiasing) rect = self._card_rect() path = QPainterPath() radius = float(tok.radius_lg) path.addRoundedRect(rect, radius, radius) p.fillPath(path, QBrush(body)) p.setPen(QPen(border, 2 if self.isSelected() else 1)) p.drawPath(path) # header stripe — a tint of the status colour, not the status colour # itself, so the card's own text stays the brightest thing on it. hdr = QRectF(rect.left(), rect.top(), rect.width(), 26) hpath = QPainterPath() hpath.addRoundedRect(hdr, radius, radius) stripe = QColor(accent) stripe.setAlpha(48) p.fillPath(hpath, QBrush(stripe)) # label p.setPen(QColor(tok.text)) f = p.font(); f.setBold(True); f.setPointSize(9); p.setFont(f) p.drawText(QRectF(10, 4, _NODE_W - 20, 20), Qt.AlignVCenter | Qt.AlignLeft, _elide(step.label, 26)) # role badge + status f.setBold(False); f.setPointSize(8); p.setFont(f) p.setPen(accent) p.drawText(QRectF(10, 30, _NODE_W - 20, 16), Qt.AlignLeft, step.role) # body: instructions preview OR sub-agent chips p.setPen(QColor(tok.text_muted)) if step.is_parallel: preview = "⇉ " + ", ".join(s.agent for s in step.sub_agents) if step.sub_agents else "⇉ (no sub-agents)" else: preview = step.instructions or "(no instructions)" p.drawText(QRectF(10, 46, _NODE_W - 20, 30), Qt.TextWordWrap | Qt.AlignTop, _elide(preview, 66)) # footer: model + skills + status dot p.setPen(QColor(tok.text_faint)) foot = [] if step.model: foot.append(step.model) if step.skills: foot.append(f"skills:{len(step.skills)}") foot.append(self.status) p.drawText(QRectF(10, _NODE_H - 18, _NODE_W - 20, 14), Qt.AlignLeft, _elide(" · ".join(foot), 34)) # ---- ports --------------------------------------------------------- # input port (top-center): hollow. output port (bottom-center): filled — # the drag handle you pull to wire an edge to another step. port_col = QColor(tok.accent) # input port (left-center): hollow. output port (right-center): filled — # the drag handle you pull to wire an edge to the next step (left→right). p.setBrush(QBrush(body)); p.setPen(QPen(port_col, 1.4)) p.drawEllipse(QPointF(1, _NODE_H / 2), _PORT_R - 1, _PORT_R - 1) p.setBrush(QBrush(port_col)); p.setPen(QPen(port_col, 1.4)) p.drawEllipse(QPointF(_NODE_W - 1, _NODE_H / 2), _PORT_R, _PORT_R) def _in_out_port(self, pos: QPointF) -> bool: d = pos - QPointF(_NODE_W, _NODE_H / 2) return (d.x() * d.x() + d.y() * d.y()) ** 0.5 <= _PORT_HIT def itemChange(self, change, value): if change == QGraphicsItem.ItemPositionHasChanged: self.node.x = float(self.pos().x()) self.node.y = float(self.pos().y()) self.canvas._reposition_edges() self.canvas.graph_changed.emit() elif change == QGraphicsItem.ItemSelectedHasChanged: # a selected/edited node comes to the front (above the edges at z=3) self.setZValue(4 if value else 2) if value: self.canvas.node_selected.emit(self.node.id) return super().itemChange(change, value) def hoverMoveEvent(self, e): # a hand cursor over the output port hints it's draggable-to-connect self.setCursor(Qt.PointingHandCursor if self._in_out_port(e.pos()) else Qt.ArrowCursor) super().hoverMoveEvent(e) def mousePressEvent(self, e): if self.canvas._connect_from is not None: self.canvas._finish_connect(self.node.id) e.accept() return if e.button() == Qt.LeftButton and self._in_out_port(e.pos()): # start a manual drag-to-connect from this node's output port self._porting = True self.canvas.begin_port_drag(self.node.id, self.mapToScene(QPointF(_NODE_W, _NODE_H / 2))) e.accept() return super().mousePressEvent(e) def mouseMoveEvent(self, e): if self._porting: self.canvas.update_port_drag(self.mapToScene(e.pos())) e.accept() return super().mouseMoveEvent(e) def mouseReleaseEvent(self, e): if self._porting: self._porting = False self.canvas.finish_port_drag(self.mapToScene(e.pos())) e.accept() return super().mouseReleaseEvent(e) def mouseDoubleClickEvent(self, e): self.canvas.node_activated.emit(self.node.id) e.accept() def contextMenuEvent(self, e): menu = QMenu() a_add = menu.addAction("+ Add next step") a_conn = menu.addAction("→ Connect from here") a_del = menu.addAction("🗑 Delete step") chosen = menu.exec(e.screenPos()) if chosen is a_add: self.canvas.add_step_below(self.node.id) elif chosen is a_conn: self.canvas.begin_connect(self.node.id) elif chosen is a_del: self.canvas.delete_node(self.node.id) e.accept() def center(self) -> QPointF: return self.pos() + QPointF(_NODE_W / 2, _NODE_H / 2) class _EdgeItem(QGraphicsPathItem): def __init__(self, edge: Edge, canvas: "Co4ECanvas"): super().__init__() self.edge = edge self.canvas = canvas self._dst: Optional[QPointF] = None # Above node cards (z=2) so a connecting line is never hidden behind a # step; a selected node bumps itself to the front while being edited. self.setZValue(3) self.setFlag(QGraphicsItem.ItemIsSelectable, True) self.setAcceptHoverEvents(True) self._hover = False self._apply_pen() def _apply_pen(self): tok = current_palette() if self.isSelected(): color, w = QColor(tok.accent), 3 elif self._hover: color, w = QColor(tok.text_muted), 3 else: color, w = QColor(tok.border_strong), 2 self.setPen(QPen(color, w, Qt.SolidLine, Qt.RoundCap, Qt.RoundJoin)) def update_path(self, points): self._dst = points[-1] if points else None self.setPath(_rounded_path(points)) def boundingRect(self): return super().boundingRect().adjusted(-10, -10, 10, 10) # room for the arrowhead def shape(self): # Widen the clickable/selectable area so a thin line is easy to grab. from PySide6.QtGui import QPainterPathStroker stroker = QPainterPathStroker() stroker.setWidth(14) return stroker.createStroke(self.path()) def hoverEnterEvent(self, e): self._hover = True self._apply_pen() self.update() super().hoverEnterEvent(e) def hoverLeaveEvent(self, e): self._hover = False self._apply_pen() self.update() super().hoverLeaveEvent(e) def paint(self, p, opt, widget=None): self._apply_pen() super().paint(p, opt, widget) # arrowhead at the target, pointing right into its (left) input port if self._dst is not None: p.setRenderHint(p.RenderHint.Antialiasing) tip = self._dst s = 7.0 tri = QPolygonF([ QPointF(tip.x() + 1, tip.y()), QPointF(tip.x() - s, tip.y() - s * 0.7), QPointF(tip.x() - s, tip.y() + s * 0.7), ]) col = self.pen().color() p.setBrush(QBrush(col)) p.setPen(QPen(col, 1)) p.drawPolygon(tri) def contextMenuEvent(self, e): menu = QMenu() act_del = menu.addAction("🗑 Delete connection") if menu.exec(e.screenPos()) is act_del: self.canvas.delete_edge(self.edge) e.accept()