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>
This commit is contained in:
2026-08-25 18:43:42 +09:00
co-authored by Claude Sonnet 5
parent ca7ea1479d
commit 0631abf85f
45 changed files with 22739 additions and 1578 deletions
@@ -0,0 +1,262 @@
"""Mixin xử lý tương tác (zoom/pan/relayout/drop) của canvas Co4E — dời khỏi
``ui/co4e_canvas.py``.
Vấn đề đang có: gộp toàn bộ ``Co4ECanvas`` (dòng 289-701 của file cũ) vào một
file duy nhất vẫn dư 413 dòng — vượt trần 400 dòng/file production của CASAN
Check 2 dù đã tách ``_NodeItem``/``_EdgeItem`` ra ``canvas_items.py`` rồi. Khối
còn lại chia làm hai nhóm trách nhiệm tự nhiên: (1) mutation đồ thị (add/delete
node/edge, port-drag) và (2) tương tác view thuần tuý (overlay góc, zoom,
pan-chuột-giữa, fit/relayout, phím tắt, kéo-thả từ sidebar). Nhóm (2) được cắt
ra đây thành MIXIN THUẦN — không có ``__init__`` riêng, không tự gọi
``super().__init__()`` — vì toàn bộ state nó dùng (``self._overlay``,
``self._zoom``, ``self._panning``, ``self._pan_start``, ``self._nodes``,
``self._edges``, ``self._scene``, ``self._connect_from``, ``self._temp_edge``,
hằng số lớp ``self._ZOOM_MIN``/``self._ZOOM_MAX``) do ``Co4ECanvas.__init__``
định nghĩa; mixin chỉ mượn ``self`` khi đã được trộn vào lớp đó.
Cách làm: cắt dán NGUYÊN VĂN các khối dòng 318-345, 484-519, 522-548, 550-610,
652-701 của ``ui/co4e_canvas.py`` — không đổi tên/tham số/thứ tự/logic, kể cả
inline import ``from collections import defaultdict`` bên trong ``relayout``
hay hai inline import ``from ..core.co4e import ...`` bên trong ``dropEvent``
(chỉ đổi SỐ DẤU CHẤM cho đúng cấp thư mục mới — xem chú thích tại chỗ).
Thứ tự kế thừa bắt buộc ở nơi dùng (``co4e_canvas_widget.py``):
``class Co4ECanvas(_CanvasInteractionMixin, QGraphicsView)`` — mixin đứng
TRƯỚC ``QGraphicsView`` trong danh sách base để MRO ưu tiên các override ở
đây (``mousePressEvent``/``mouseMoveEvent``/``mouseReleaseEvent``/
``wheelEvent``/``resizeEvent``/``keyPressEvent``/``dragEnterEvent``/
``dragMoveEvent``/``dropEvent``) thay vì rơi vào bản gốc của
``QGraphicsView``. Mỗi ``super().xxxEvent(e)`` gọi trong file này dựa vào đúng
thứ tự MRO đó để rơi xuống ``QGraphicsView.xxxEvent`` khi mixin không tự xử lý
— không phải gọi đệ quy lại chính nó.
"""
from __future__ import annotations
import copy
import json
from typing import Dict, Optional
from PySide6.QtCore import QPointF, Qt
from ...core.co4e import Edge, Node, compute_waves, new_edge_id, new_node_id
from .canvas_items import CO4E_MIME, _NODE_H, _NODE_W, _NodeItem
class _CanvasInteractionMixin:
"""Phần tương tác view của ``Co4ECanvas``: overlay góc, zoom/pan, fit/
relayout, phím tắt, kéo-thả từ sidebar. Xem docstring đầu module về lý do
tách và ràng buộc thứ tự kế thừa MRO khi trộn vào ``Co4ECanvas``."""
# ---- bottom-left overlay (zoom / fit) --------------------------------
def add_overlay(self, widget) -> None:
self._overlay = widget
widget.setParent(self.viewport())
widget.show()
widget.raise_()
self._place_overlay()
def _place_overlay(self) -> None:
if self._overlay is not None:
self._overlay.adjustSize()
vp = self.viewport()
self._overlay.move(12, vp.height() - self._overlay.height() - 12)
self._overlay.raise_()
def resizeEvent(self, e): # noqa: N802
super().resizeEvent(e)
self._place_overlay()
def scrollContentsBy(self, dx, dy): # noqa: N802
# QGraphicsView scrolls the viewport's child widgets along with the
# scene, so panning/scrolling would drag the zoom overlay off-corner.
# Re-pin it after every scroll so +/−/fit stay fixed in place.
super().scrollContentsBy(dx, dy)
self._place_overlay()
def showEvent(self, e): # noqa: N802
super().showEvent(e)
self._place_overlay() # viewport size is final once shown
# ---- zoom / fit -------------------------------------------------------
def _zoom_by(self, factor: float) -> None:
# Derive the CURRENT scale from the live transform (never a separate
# accumulator that can drift out of sync with fit_view/relayout/reset —
# that drift is what made the +/− buttons and Ctrl+wheel randomly stop
# working). Clamp the TARGET to the range and apply the exact factor to
# reach it, so zooming still works right up to the limits.
cur = self.transform().m11() or 1.0
target = max(self._ZOOM_MIN, min(self._ZOOM_MAX, cur * factor))
if abs(target - cur) < 1e-6:
return
self.scale(target / cur, target / cur)
self._zoom = target
def zoom_in(self) -> None:
self._zoom_by(1.15)
def zoom_out(self) -> None:
self._zoom_by(1 / 1.15)
def reset_zoom(self) -> None:
self.resetTransform()
self._zoom = 1.0
def wheelEvent(self, e):
# Ctrl+wheel = zoom (anchored under the cursor); Shift+wheel = pan
# horizontally; plain wheel scrolls vertically.
if e.modifiers() & Qt.ControlModifier:
self._zoom_by(1.15 if e.angleDelta().y() > 0 else 1 / 1.15)
e.accept()
return
if e.modifiers() & Qt.ShiftModifier:
bar = self.horizontalScrollBar()
bar.setValue(bar.value() - e.angleDelta().y())
e.accept()
return
super().wheelEvent(e)
# ---- middle-mouse drag-to-pan ----------------------------------------
def mousePressEvent(self, e):
if e.button() == Qt.MiddleButton:
self._panning = True
self._pan_start = e.position().toPoint()
self.setCursor(Qt.ClosedHandCursor)
e.accept()
return
super().mousePressEvent(e)
def mouseMoveEvent(self, e):
if self._panning and self._pan_start is not None:
pos = e.position().toPoint()
delta = pos - self._pan_start
self._pan_start = pos
self.horizontalScrollBar().setValue(self.horizontalScrollBar().value() - delta.x())
self.verticalScrollBar().setValue(self.verticalScrollBar().value() - delta.y())
e.accept()
return
super().mouseMoveEvent(e)
def mouseReleaseEvent(self, e):
if e.button() == Qt.MiddleButton and self._panning:
self._panning = False
self.setCursor(Qt.ArrowCursor)
e.accept()
return
super().mouseReleaseEvent(e)
def fit_view(self) -> None:
"""Auto-fit: zoom/pan so every node is visible with a small margin."""
rect = self._scene.itemsBoundingRect()
if rect.isNull():
return
self.setSceneRect(rect.adjusted(-60, -60, 60, 60))
self.fitInView(rect.adjusted(-40, -40, 40, 40), Qt.KeepAspectRatio)
# keep the zoom accumulator in sync with the transform fitInView applied
self._zoom = self.transform().m11() or 1.0
def relayout(self, hgap: float = 110.0, vgap: float = 40.0) -> None:
"""Arrange nodes LEFT→RIGHT by dependency depth: each topological wave is
a column (x = wave), siblings stacked vertically within it. Used to turn
an old top-down graph into the horizontal flow layout."""
nodes = [it.node for it in self._nodes.values()]
edges = [it.edge for it in self._edges]
if not nodes:
return
waves = compute_waves(nodes, edges)
from collections import defaultdict
cols: Dict[int, list] = defaultdict(list)
for n in nodes:
cols[waves.get(n.id, 0)].append(n)
for w in sorted(cols):
for row, n in enumerate(sorted(cols[w], key=lambda nn: (nn.y, nn.x))):
item = self._nodes.get(n.id)
if item is not None:
item.setPos(w * (_NODE_W + hgap), row * (_NODE_H + vgap))
self._reposition_edges()
def relayout_if_vertical(self) -> None:
"""Convert a graph that's stacked vertically (the old top-down layout, or
overlapping nodes) into the horizontal left→right layout — but leave a
graph the user already arranged horizontally untouched."""
nodes = [it.node for it in self._nodes.values()]
if len(nodes) < 2:
return
xs = [n.x for n in nodes]
if max(xs) - min(xs) < _NODE_W: # all in one column → it's vertical
self.relayout()
def add_workflow(self, nodes, edges, at: Optional[QPointF] = None) -> None:
"""Drop/merge a whole flow's nodes+edges onto the canvas with fresh ids
(so the same template can be dropped several times). Offsets it near
``at`` when given, else tiles it beside whatever is already there."""
remap: Dict[str, str] = {}
# offset so a dropped template doesn't land exactly on existing nodes
ox = (at.x() - nodes[0].x) if (at and nodes) else (60 if self._nodes else 0)
oy = (at.y() - nodes[0].y) if (at and nodes) else (60 if self._nodes else 0)
for n in nodes:
new = Node(id=new_node_id(), x=n.x + ox, y=n.y + oy, data=copy.deepcopy(n.data))
remap[n.id] = new.id
item = _NodeItem(new, self)
self._nodes[new.id] = item
self._scene.addItem(item)
for e in edges:
s, t = remap.get(e.source), remap.get(e.target)
if s and t:
self._add_edge_item(Edge(id=new_edge_id(s, t), source=s, target=t))
self._reposition_edges()
self.graph_changed.emit()
# ---- key / drop -------------------------------------------------------
def keyPressEvent(self, e):
if e.key() in (Qt.Key_Delete, Qt.Key_Backspace):
self.delete_selected()
return
if e.key() == Qt.Key_Escape:
self._connect_from = None
if self._temp_edge is not None:
self._scene.removeItem(self._temp_edge)
self._temp_edge = None
self._port_src = None
return
if e.key() in (Qt.Key_Plus, Qt.Key_Equal) and (e.modifiers() & Qt.ControlModifier):
self.zoom_in(); return
if e.key() == Qt.Key_Minus and (e.modifiers() & Qt.ControlModifier):
self.zoom_out(); return
if e.key() == Qt.Key_0 and (e.modifiers() & Qt.ControlModifier):
self.reset_zoom(); return
super().keyPressEvent(e)
def dragEnterEvent(self, e):
if e.mimeData().hasFormat(CO4E_MIME):
e.acceptProposedAction()
else:
super().dragEnterEvent(e)
def dragMoveEvent(self, e):
if e.mimeData().hasFormat(CO4E_MIME):
e.acceptProposedAction()
else:
super().dragMoveEvent(e)
def dropEvent(self, e):
if not e.mimeData().hasFormat(CO4E_MIME):
super().dropEvent(e)
return
try:
payload = json.loads(bytes(e.mimeData().data(CO4E_MIME)).decode("utf-8"))
except (ValueError, UnicodeDecodeError):
return
pos = self.mapToScene(e.position().toPoint())
if isinstance(payload, dict) and payload.get("kind") == "workflow":
# A whole flow dragged from the sidebar → merge its graph in.
# 3 dấu chấm vì file này giờ nằm ở presentation/co4e/ (sâu hơn
# ui/ gốc 1 cấp) — cùng module core.co4e như bản gốc, chỉ đổi số
# cấp cho đúng vị trí mới, không đổi cái được import.
from ...core.co4e import workflow_from_dict
wf = workflow_from_dict(payload.get("workflow", {}))
if wf.nodes:
self.add_workflow(wf.nodes, wf.edges, at=pos)
else:
from ...core.co4e import step_from_dict
self.add_palette_step(step_from_dict(payload), pos)
e.acceptProposedAction()