"""Icons: hand-painted panel-collapse toggles + a shared line-icon library rendered from local SVG path data — no external image files, no network fetch, so every icon stays crisp at any size and recolors for the light/dark theme. The glyph set is ported 1:1 from the Nova Platform web app's shared icon library (``nova-platform/apps/web/components/ui/icons.tsx``) so the desktop app and the web platform show the SAME icons. Same Feather-style thin stroke, same ``viewBox 0 0 24 24``, same ``stroke-width`` (1.7) — the only difference is the render path (Qt ``QSvgRenderer`` here vs. React ```` there). Entries fall into three groups below: (1) the full Nova set under Nova's own names; (2) a few app-specific glyphs Nova doesn't define (save/new/attach/…), drawn in the same thin-stroke style; (3) legacy aliases so this app's existing ``icon("chat"/"document"/"flask"/"sparkle"/"settings")`` call sites keep working — each alias points at the matching Nova design (message/file/beaker/sparkles/ gear). Feather Icons and the derived Nova set are MIT-licensed.""" from __future__ import annotations from PySide6.QtCore import QByteArray, QRectF, Qt from PySide6.QtGui import QBrush, QColor, QIcon, QPainter, QPen, QPixmap from PySide6.QtSvg import QSvgRenderer from PySide6.QtWidgets import QComboBox, QHBoxLayout, QLabel, QWidget def _default_color() -> str: """The default icon tint: the theme's muted text colour, so glyphs sit at the same weight as the labels beside them. Resolved per call — icons are painted bitmaps, so a theme switch must repaint them, not restyle them.""" from ..theme import current_palette return current_palette().text_muted def _hidpi_pixmap(size: int) -> QPixmap: """A transparent pixmap sized for the current display's pixel ratio (with the ratio set on it) so icons render crisply on HiDPI/scaled screens — a plain ``QPixmap(size, size)`` is only ``size`` device pixels and looks blurry when the OS scales it up. A QPainter on this draws in logical (size) coordinates.""" # A plain fixed-size transparent pixmap — exactly ``size`` px, no supersample # (the earlier HiDPI supersampling made icons look oversized on scaled # displays). Icon display size is governed by each widget's iconSize. pm = QPixmap(size, size) pm.fill(Qt.transparent) return pm # Inner content of a 24x24 stroke-based SVG (viewBox/stroke attrs added by # `icon()` below). One entry per semantic action, reused across every tab/ # dialog so the same concept always gets the same glyph. _PATHS = { # ---- Nova set: navigation / areas --------------------------------- "dashboard": '' '', "schedule": '' '', "workspaces": '', "cowork": '', "flow": '' '', "graph": '' '', "book": '' '', "monitoring": '', "list": '' '' '', "server": '' '', "box": '' '', "shield": '', "sliders": '' '' '' '' '', "users": '' '', "user": '', "briefcase": '' '', "award": '', "gear": '', "globe": '' '', "logout": '' '', "panel": '', # ---- Nova set: actions / objects ---------------------------------- "plus": '', "edit": '' '', "trash": '' '', "play": '', "pause": '', "stop": '', "sparkles": '' '', "refresh": '' '', "folder": '', "file": '' '', "code": '', "terminal": '', "robot": '' '' '', "puzzle": '', "search": '', "close": '', "upload": '' '', "download": '' '', "link": '' '', "lock": '', "bell": '', "tag": '', "clock": '', "filter": '', "eye": '', "chart": '' '', "database": '' '' '', "cpu": '' '' '' '' '', "cloud": '', "beaker": '' '', "compass": '', "bolt": '', "star": '', "flag": '' '', "wrench": '', "message": '', "send": '', "branch": '' '', "alert": '' '', "plug": '' '', "factory": '' '' '', "ruler": '' '', "pin": '' '', # ---- App-specific glyphs Nova doesn't define (same thin-stroke) ---- "save": '' '', "new": '' '' '', "minus": '', "shuffle": '' '' '', "unlock": '', "key": '', "attach": '', "compress": '' '', "network": '' '', "monitor": '' '', "sun": '' '' '' '' '', "moon": '', "chevron-left": '', "chevron-right": '', "chevron-down": '', "chevron-up": '', # A plain checkmark (kept as-is): the app uses "check" for a run-the-check # action button, where a bare tick reads better than Nova's box+check. "check": '', # ---- Legacy aliases → matching Nova design (keep old call sites working) -- "chat": '', # = message "document": '' '', # = file "flask": '' '', # = beaker "sparkle": '' '', # = sparkles "settings": '', # = gear } def all_icon_names() -> list: """Every icon name usable by :func:`icon` — the built-in line-icon set plus any custom icons added via Monitoring's Icon Management (icons_admin_tab). Backs the icon-picker dropdown offered wherever a step/agent/flow icon is chosen, so users pick from this SAME registry instead of typing a name.""" from ..core import custom_icons try: custom = custom_icons.list_custom() except Exception: # noqa: BLE001 — the picker must never crash on a bad read custom = [] return sorted(set(_PATHS) | set(custom)) def icon_picker_combo(current: str = "") -> QComboBox: """An editable dropdown of every icon name (see :func:`all_icon_names`), each row previewing its actual glyph — so choosing a step/agent icon is a quick pick from Monitoring's icon registry instead of typing a name from memory. Still editable: a name not yet in the list (e.g. a custom icon about to be added) can be typed directly, same as before.""" combo = QComboBox() combo.setEditable(True) for name in all_icon_names(): combo.addItem(icon(name, size=14), name) idx = combo.findText(current) if current else -1 if idx >= 0: combo.setCurrentIndex(idx) else: combo.setCurrentText(current or "") return combo def icon(name: str, size: int = 16, color: str | None = None) -> QIcon: """A flat thin-line icon for ``name`` (see ``_PATHS`` for the full list), tinted ``color`` — rendered from local SVG data, no image files/network. Stroke width 1.7 matches the Nova Platform web app's shared icon set.""" color = color or _default_color() # A user-added custom icon (full SVG under ~/.cowork_local/icons) is rendered # as-is (keeps its own colours). Then built-in glyphs; then a neutral fallback. if name not in _PATHS: try: from ..core import custom_icons custom_svg = custom_icons.get_svg(name) except Exception: # noqa: BLE001 — icon lookup must never crash the UI custom_svg = None if custom_svg: renderer = QSvgRenderer(QByteArray(custom_svg.encode("utf-8"))) pm = _hidpi_pixmap(size) p = QPainter(pm) p.setRenderHint(QPainter.Antialiasing) renderer.render(p) p.end() return QIcon(pm) # Unknown names must never crash the UI — fall back to a neutral glyph. body = _PATHS.get(name) or _PATHS.get("sparkle") or "" svg = (f'{body}') renderer = QSvgRenderer(QByteArray(svg.encode("utf-8"))) pm = _hidpi_pixmap(size) p = QPainter(pm) p.setRenderHint(QPainter.Antialiasing) renderer.render(p) p.end() return QIcon(pm) def _panel_icon(fill_left: bool, size: int = 16, color: str | None = None) -> QIcon: """A rounded panel split by a divider, with one narrow side filled solid (the 'sidebar' toggle look).""" color = color or _default_color() pm = _hidpi_pixmap(size) p = QPainter(pm) p.setRenderHint(QPainter.Antialiasing) col = QColor(color) p.setPen(QPen(col, 1.5)) rect = QRectF(2.0, 3.0, size - 4.0, size - 6.0) p.drawRoundedRect(rect, 3.0, 3.0) col_w = rect.width() * 0.34 if fill_left: bar_x = rect.left() + col_w fill = QRectF(rect.left() + 1.2, rect.top() + 1.2, col_w - 1.6, rect.height() - 2.4) else: bar_x = rect.right() - col_w fill = QRectF(bar_x + 0.4, rect.top() + 1.2, col_w - 1.6, rect.height() - 2.4) p.drawLine(int(bar_x), int(rect.top() + 1), int(bar_x), int(rect.bottom() - 1)) p.fillRect(fill, QBrush(col)) p.end() return QIcon(pm) def collapse_left_icon() -> QIcon: """Panel with the left strip filled — collapse toward the left. Rendered at the same 16px as ``icon()`` so it sits flush with the nav-rail icons (Dashboard etc.) instead of looking one size up.""" return _panel_icon(fill_left=True) def collapse_right_icon() -> QIcon: """Panel with the right strip filled — collapse toward the right.""" return _panel_icon(fill_left=False) def pixmap(name: str, size: int = 16, color: str | None = None) -> QPixmap: """The line-icon ``name`` as a QPixmap (for QLabel.setPixmap — QLabel has no setIcon). Same glyph/renderer as ``icon()``.""" return icon(name, size, color).pixmap(size, size) # Status-LED colours — a filled dot, the one place a solid glyph (not a line # icon) is the right metaphor for an on/off/running indicator. # # Deliberately the SAME in light and dark. An LED means one thing regardless of # theme, and these mid-saturation hues clear 3:1 against both #0B0B0C and # #FFFFFF, so a status dot never has to be re-learned. Everything else in the # UI goes through theme.palette(); this is the documented exception. DOT_GREEN = "#2EA043" DOT_RED = "#E5484D" DOT_AMBER = "#B7791F" DOT_BLUE = "#4C7BE8" DOT_GREY = "#8B8B94" def dot_icon(color: str = DOT_GREY, size: int = 12) -> QIcon: """A small filled status dot (LED). Used for on/off/running indicators where a colored dot reads better than a line glyph.""" pm = _hidpi_pixmap(size) p = QPainter(pm) p.setRenderHint(QPainter.Antialiasing) p.setPen(Qt.NoPen) p.setBrush(QBrush(QColor(color))) m = size * 0.22 p.drawEllipse(QRectF(m, m, size - 2 * m, size - 2 * m)) p.end() return QIcon(pm) class IconLabel(QWidget): """A line-icon shown immediately to the left of a text label — the standard replacement for the old "🔒 Some text" emoji-prefixed QLabels. ``set_text`` updates just the text; ``set_icon`` swaps the glyph/color, so dynamic status labels (lock/unlock, …) keep working.""" def __init__(self, name: str, text: str = "", *, size: int = 16, color: str | None = None, gap: int = 6, parent=None): """Một nhãn có biểu tượng đứng trước chữ, dùng cho các hàng thông tin.""" super().__init__(parent) self._size = size lay = QHBoxLayout(self) lay.setContentsMargins(0, 0, 0, 0) lay.setSpacing(gap) self._icon = QLabel() self._icon.setPixmap(pixmap(name, size, color)) self._text = QLabel(text) lay.addWidget(self._icon) lay.addWidget(self._text) lay.addStretch(1) def set_text(self, text: str) -> None: """Đổi phần chữ của nhãn.""" self._text.setText(text) def setText(self, text: str) -> None: # noqa: N802 — QLabel-compatible alias """Bí danh hợp chuẩn ``QLabel`` của :meth:`set_text`, để thay thế trực tiếp cho một ``QLabel`` mà không phải sửa chỗ gọi. """ self._text.setText(text) def set_icon(self, name: str, color: str | None = None) -> None: """Đổi icon (và màu icon) của nhãn.""" self._icon.setPixmap(pixmap(name, self._size, color)) def text_label(self) -> QLabel: """The inner text QLabel (for styling — setStyleSheet, etc.).""" return self._text