Files
cowork-local/presentation/co4e/canvas_geometry.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

127 lines
5.5 KiB
Python

"""Hình học thuần cho canvas Co4E — tách khỏi ``ui/co4e_canvas.py``.
Vấn đề đang có: ``ui/co4e_canvas.py`` dài hơn 2000 dòng, gộp chung widget Qt
(``QGraphicsItem``, vẽ, sự kiện chuột) với các hàm hình học thuần (khoảng cách,
nội suy điểm, dựng đường bo góc, né vật cản, cắt chuỗi). Các hàm hình học này
không cần ``QApplication``, không vẽ, không đọc kích thước widget — chúng chỉ
dùng ``QPointF``/``QRectF``/``QPainterPath`` như kiểu giá trị thuần. Gộp chung
vào một file khiến file đó khó đọc và khó kiểm tra theo giới hạn CASAN (≤400
dòng mỗi file production).
Cách làm: dời nguyên 8 hàm này sang đây, không đổi tên/tham số/giá trị mặc
định/hành vi — kể cả các "quirk" đã bị characterization test đóng đinh (xem
``tests/characterization/test_co4e_canvas_geometry.py``), ví dụ ``_route`` có
thể "bỏ cuộc" và trả về elbow va chạm nếu bị vật cản bao kín hoàn toàn, hoặc
``_elide(text, 0)`` trả về ``"…"`` chứ không phải chuỗi rỗng do cách slicing
``text[: n - 1]``. Đừng "sửa" các quirk này ở đây — chúng đã có test khoá lại,
sửa sai chỗ này sẽ làm vỡ hợp đồng mà nơi khác đang phụ thuộc.
``ui/co4e_canvas.py`` import lại các tên này (không alias) để giữ nguyên đường
import public mà các test/character khác đang dùng.
"""
from __future__ import annotations
from PySide6.QtCore import QPointF, QRectF
from PySide6.QtGui import QPainterPath
_CORNER_R = 12 # edge elbow corner radius
def _dist(a: QPointF, b: QPointF) -> float:
return ((a.x() - b.x()) ** 2 + (a.y() - b.y()) ** 2) ** 0.5
def _towards(a: QPointF, b: QPointF, d: float) -> QPointF:
dist = _dist(a, b)
if dist < 1e-6:
return QPointF(a)
t = d / dist
return QPointF(a.x() + (b.x() - a.x()) * t, a.y() + (b.y() - a.y()) * t)
def _rounded_path(points, r: float = _CORNER_R) -> QPainterPath:
"""Build a path through axis-aligned ``points`` with rounded corners at each
bend ("vuông bo cong ở góc")."""
if not points:
return QPainterPath()
path = QPainterPath(points[0])
if len(points) == 1:
return path
for i in range(1, len(points) - 1):
prev, cur, nxt = points[i - 1], points[i], points[i + 1]
rr = min(r, _dist(prev, cur) / 2.0, _dist(cur, nxt) / 2.0)
path.lineTo(_towards(cur, prev, rr))
path.quadTo(cur, _towards(cur, nxt, rr))
path.lineTo(points[-1])
return path
def _seg_hits_rect(p1: QPointF, p2: QPointF, rect: QRectF) -> bool:
"""Axis-aligned segment vs rectangle overlap (all routed segments are H or V)."""
x1, y1, x2, y2 = p1.x(), p1.y(), p2.x(), p2.y()
if abs(y1 - y2) < 0.5: # horizontal
if rect.top() <= y1 <= rect.bottom():
lo, hi = sorted((x1, x2))
return not (hi < rect.left() or lo > rect.right())
return False
if abs(x1 - x2) < 0.5: # vertical
if rect.left() <= x1 <= rect.right():
lo, hi = sorted((y1, y2))
return not (hi < rect.top() or lo > rect.bottom())
return False
box = QRectF(QPointF(min(x1, x2), min(y1, y2)), QPointF(max(x1, x2), max(y1, y2)))
return rect.intersects(box)
def _hits(points, obstacles) -> bool:
for i in range(len(points) - 1):
for r in obstacles:
if _seg_hits_rect(points[i], points[i + 1], r):
return True
return False
def _route(src: QPointF, dst: QPointF, obstacles=None):
"""Waypoints for a LEFT→RIGHT orthogonal edge from ``src`` (a node's right
output) to ``dst`` (the next node's left input) that AVOIDS the other node
rectangles: try the straight elbow, then a clear vertical band, then a
top/bottom detour — so a connector never overlaps or hides behind a step."""
obstacles = list(obstacles or [])
if abs(src.y() - dst.y()) < 1.5:
cand = [src, dst]
if not _hits(cand, obstacles):
return cand
mid_x = (src.x() + dst.x()) / 2.0
base = [src, QPointF(mid_x, src.y()), QPointF(mid_x, dst.y()), dst]
if not _hits(base, obstacles):
return base
# 1) slide the vertical run to a clear band between the two columns
lo, hi = min(src.x(), dst.x()) + 6, max(src.x(), dst.x()) - 6
if hi > lo:
for frac in (0.5, 0.35, 0.65, 0.2, 0.8):
x = lo + (hi - lo) * frac
cand = [src, QPointF(x, src.y()), QPointF(x, dst.y()), dst]
if not _hits(cand, obstacles):
return cand
# 2) detour above/below every obstacle, then back in
margin = 44.0
ys = [src.y(), dst.y()] + [r.top() for r in obstacles] + [r.bottom() for r in obstacles]
out_x, in_x = src.x() + 34, dst.x() - 34 # short stubs out of the side ports
for side_y in (min(ys) - margin, max(ys) + margin):
cand = [src, QPointF(out_x, src.y()), QPointF(out_x, side_y),
QPointF(in_x, side_y), QPointF(in_x, dst.y()), dst]
if not _hits(cand, obstacles):
return cand
return base
def _ortho_path(src: QPointF, dst: QPointF, r: float = _CORNER_R) -> QPainterPath:
"""Rounded orthogonal elbow (no obstacle avoidance) — used for the transient
drag-to-connect line and by callers that pass no obstacles."""
return _rounded_path(_route(src, dst), r)
def _elide(text: str, n: int) -> str:
text = (text or "").replace("\n", " ")
return text if len(text) <= n else text[: n - 1] + "…"