Hồi quy đã vá
-------------
F-12 Kéo–thả hoặc dán tệp vào ô chat ném NameError. R08 tách `_Input` sang
`chat_input_box.py` nhưng để `_paths_from_mime()` ở lại
`composer_widget.py`, nên hai hàm sự kiện Qt gọi một cái tên không tồn
tại. Bốn hàm dùng chung chuyển sang `composer_mime.py` — module thứ ba
là chỗ duy nhất không lặp lại được lỗi này. Đo lại: cả thả lẫn dán đều
gắn 1 tệp, khớp bản trước refactor.
F-01 Đổi provider thì bộ chọn model AI-Edit không làm gì. Hook cũ kiểm
`folder.ai_model_combo`, thuộc tính R08-T12 đã dời sang
`ai_panel.resolver`. Làm mới vô điều kiện, đúng như tab cũ: lần lấy đầu
tiên hỏng thì đổi provider chính là lúc phải thử lại.
F-07 Hàng chọn kỳ của Dashboard bị đẩy xuống dưới các thẻ số liệu. Hàng này
lọc CẢ BA thẻ con chứ không riêng biểu đồ, nên để nó nằm dưới là bắt
người dùng đọc con số trước khi thấy con số đó tính cho kỳ nào. Kèm
theo: `TokenUsageCardWidget` bị bỏ sót `setContentsMargins(0,0,0,0)`
mà hai thẻ con còn lại đã có, đẩy cả hàng thẻ lệch 9px.
`check_layout_geometry` nay khớp TỪNG BYTE với bản trước refactor.
F-11 Hai lớp khai trùng tên phương thức; Python giữ bản sau nên bản đầu là
mã chết. `co4e_tab.py::showEvent` bản đầu gọi `_narrow_guard.attach()`
và không bao giờ chạy.
Tách file (F-09)
----------------
Bốn file chạm trần 400 dòng, mỗi lần cắt ra một trách nhiệm thật:
graph_renderer.py -> graph_scene_builder.py + graph_export.py
co4e_workflow_service.py -> co4e_run_history.py
json_config_repository.py -> config_sections.py
agents_admin_tab.py -> shared/agent_kind_visuals.py
File cuối còn xoá 3 bản sao của hàm đã có trong `shared/formatters.py`,
giống hệt đến từng dòng — nay định dạng thời gian và avatar không lệch nhau
giữa các bảng Giám sát nữa.
Docstring
---------
41,6% -> 100% (3.478/3.478 định nghĩa production), kể cả module dormant và
phương thức dunder. Toàn bộ phần bổ sung viết bằng tiếng Việt; comment tiếng
Anh có sẵn giữ nguyên — dịch ngược là một đợt riêng.
Seam chưa nối dây (F-05)
------------------------
9 seam mang nhãn `SEAM · dựng <ngày>` kèm hai câu: được nối khi nào, và để
dormant thì hỏng gì. Ngày lấy từ lịch sử git, không phải hạn tự đặt. Gate O
đọc nhãn đó và nhắc khi quá 30 ngày.
859 test xanh · 4/4 cổng CASAN · 19/24 checker khớp từng byte bản cũ.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
139 lines
6.1 KiB
Python
139 lines
6.1 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:
|
|
"""Khoảng cách Euclid giữa hai điểm."""
|
|
return ((a.x() - b.x()) ** 2 + (a.y() - b.y()) ** 2) ** 0.5
|
|
|
|
|
|
def _towards(a: QPointF, b: QPointF, d: float) -> QPointF:
|
|
"""Điểm cách ``a`` một đoạn ``d`` theo hướng đi về ``b``.
|
|
|
|
Hai điểm trùng nhau thì trả về chính ``a``, tránh chia cho 0.
|
|
"""
|
|
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:
|
|
"""Đường gấp khúc này có cắt qua hình chữ nhật vật cản nào không.
|
|
|
|
Dùng để định tuyến đường nối lách qua các node khác.
|
|
"""
|
|
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:
|
|
"""Cắt chuỗi cho vừa ``n`` ký tự và thay xuống dòng bằng dấu cách — nhãn trên
|
|
khung vẽ chỉ có một dòng.
|
|
"""
|
|
text = (text or "").replace("\n", " ")
|
|
return text if len(text) <= n else text[: n - 1] + "…"
|