Files
cowork-local/presentation/co4e/canvas_items.py
lamhv7andClaude Sonnet 5 0631abf85f feat(co4e): tách 6 widget UI khỏi ui/co4e_tab.py sang presentation/co4e/*
Lane N3 (Co4E Studio) — dùng bộ workflow refactor-god-file, mỗi bước có
characterization test trước khi tách, hậu kiểm ranh giới tầng sau mỗi bước:

- skills_list_panel.py / agent_list_panel.py — 2 khu vực sidebar
- co4e_canvas_widget.py + canvas_items.py + canvas_interaction_mixin.py —
  Co4ECanvas tách 3 file (vượt 400 dòng nếu đứng một mình)
- node_property_panel.py + node_property_actions_mixin.py +
  step_config_section.py — StepConfigPanel, cùng lý do
- co4e_run_control_widget.py — RunsPagePanel (trang Flow Status)
- co4e_chat_view.py — ChatPanel + _ChatInput + helper autocomplete
- palette_list.py — _PaletteList dời khỏi ui/co4e_tab.py, hết import ngược
  presentation -> ui (agent/skills panel giờ import top-level)

ui/co4e_tab.py giảm 2089 -> 1878 dòng, chỉ còn phần wiring + business logic
(Co4ERunManager/AgentWorker chưa đổi — nằm ngoài phạm vi này, xem docstring
presentation/co4e/co4e_tab.py). ui/co4e_canvas.py và ui/co4e_config_panel.py
còn lại là compat shim re-export, không đổi API cho bên gọi.

Thêm tests/test_co4e_integration.py — dựng thật Co4ETab qua build_co4e_tab(),
lái luồng qua nhiều panel trong cùng instance (thêm node, mở/gập chat, chuyển
trang Flow Status rồi quay lại không mất state canvas) — bắt lỗi wiring
xuyên-panel mà characterization test từng panel riêng không thấy được.

Đã xác minh: pytest 348 passed/1 skipped, tools/check_co4e.py sạch, không
file nào >400 dòng, domain/application không import PySide6, và so pixel
before/after (git worktree tại HEAD cũ) ra 0/1.125.000 pixel khác biệt.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-25 18:43:42 +09:00

285 lines
12 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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()