"""Cách vẽ một bong bóng chat: màu, đường thời gian, diff, trạng thái — R08-T01. Tách khỏi ``chat_history_widget.py``: đây là phần quyết định TRÔNG THẾ NÀO, còn file kia quyết định HIỆN CÁI GÌ. ``diff_to_html`` tô màu phần thêm/bớt khi agent sửa file; ``_TimelineGutter`` vẽ đường dọc nối các lượt, ``ThinkingIndicator`` là ba chấm lúc chờ. """ from __future__ import annotations import html from pathlib import Path from PySide6.QtCore import QPointF, Qt, QTimer, Signal from PySide6.QtGui import QColor, QPainter, QPen, QPixmap from PySide6.QtWidgets import ( QFrame, QHBoxLayout, QLabel, QPushButton, QScrollArea, QTextBrowser, QVBoxLayout, QWidget, ) from ...i18n import on_language_changed, tr from ...theme import palette, resolve_theme from ...config import CONFIG_DIR from ...ui.osutil import is_image, open_folder, open_path def _app_theme() -> str: """Resolve the current app theme (light or dark) from config.""" try: import json with open(CONFIG_DIR / "config.json", "r", encoding="utf-8") as f: data = json.load(f) return resolve_theme(data.get("theme", "dark")) except Exception: # noqa: BLE001 return "dark" def _p(): """Design tokens for the theme in effect right now.""" return palette(_app_theme()) def _dot_color(role: str) -> str: """Timeline dot colour for a message role.""" p = _p() return { "user": p.role_user, "assistant": p.role_assistant, "tool": p.role_tool, "error": p.role_error, "success": p.role_result, }.get(role, p.text_faint) class _TimelineGutter(QWidget): """The left rail of the point-conversation: a vertical connector line with a role-colored dot near the top, so stacked messages read as a timeline (Claude-Code style) instead of separate boxes.""" def __init__(self, role: str): """Dải dọc bên trái mỗi bong bóng, màu theo vai người nói.""" super().__init__() self._role = role self.setFixedWidth(22) def set_role(self, role: str) -> None: """Đổi vai trò (người dùng/trợ lý/tool/lỗi) và vẽ lại chấm mốc.""" self._role = role self.update() def paintEvent(self, _e): # noqa: N802 """Vẽ dải mốc thời gian bên trái bong bóng: một đường mờ chạy suốt chiều cao cộng một chấm màu theo vai trò — nối lại thành một trục liền mạch cho cả dòng thời gian. """ p = QPainter(self) p.setRenderHint(QPainter.Antialiasing) tok = _p() x = 11.0 cy = 15.0 # connector line (faint) running the full height → continuous rail p.setPen(QPen(QColor(tok.border), 2)) p.drawLine(int(x), 0, int(x), self.height()) # a background ring lifts the dot off the line p.setPen(Qt.NoPen) p.setBrush(QColor(tok.bg)) p.drawEllipse(QPointF(x, cy), 7.5, 7.5) p.setBrush(QColor(_dot_color(self._role))) p.drawEllipse(QPointF(x, cy), 4.5, 4.5) def _diff_legend(diff_text: str) -> str: """A small badge pair labeling what the colors mean: 'Before → After' for an edit, or a single 'Added'/'Removed' badge for a pure create/delete — so the before/after distinction is explicit, not just implied by color.""" has_add = any(ln.startswith("+") and not ln.startswith("+++") for ln in diff_text.splitlines()) has_del = any(ln.startswith("-") and not ln.startswith("---") for ln in diff_text.splitlines()) p = _p() def pill(bg: str, fg: str, key: str) -> str: """Một nhãn viên thuốc (nền + chữ) trong chú giải diff.""" return (f'{html.escape(tr(key))}') if has_add and has_del: badge = (pill(p.diff_del_bg, p.diff_del_fg, "chat.diff_before") + f' → ' + pill(p.diff_add_bg, p.diff_add_fg, "chat.diff_after")) elif has_add: badge = pill(p.diff_add_bg, p.diff_add_fg, "chat.diff_added") elif has_del: badge = pill(p.diff_del_bg, p.diff_del_fg, "chat.diff_removed") else: return "" return f'