Files
cowork-local/ui/widgets.py
T
2026-08-09 20:12:05 +07:00

387 lines
14 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Reusable widgets: a collapsible list section, a plan checklist, a thin
collapse strip, and a scroll-wheel guard for value widgets in scrollable
forms."""
from __future__ import annotations
from pathlib import Path
from PySide6.QtCore import QEvent, QObject, QPointF, QRectF, Qt, Signal
from PySide6.QtGui import QColor, QPainter, QPen
from PySide6.QtWidgets import (
QAbstractSpinBox, QComboBox, QDoubleSpinBox, QFrame, QGraphicsDropShadowEffect,
QHBoxLayout, QLabel, QListWidget, QListWidgetItem, QPushButton, QSizePolicy,
QVBoxLayout, QWidget,
)
from ..core.flows import STEP_DONE, STEP_ERROR, STEP_PENDING, STEP_RUNNING
from ..theme import ACCENT
from .icons import DOT_BLUE, DOT_GREEN, DOT_GREY, DOT_RED, dot_icon, icon
class StatCard(QFrame):
"""A titled value card (e.g. token count + its cost as the subtitle) —
shared by Dashboard and Monitoring's token/cost displays."""
def __init__(self):
super().__init__()
self.setFrameShape(QFrame.StyledPanel)
self.setStyleSheet(
"QFrame { border: 1px solid rgba(140,146,152,0.35); border-radius: 16px; }")
shadow = QGraphicsDropShadowEffect(self)
shadow.setBlurRadius(18)
shadow.setOffset(0, 3)
shadow.setColor(QColor(0, 0, 0, 60))
self.setGraphicsEffect(shadow)
lay = QVBoxLayout(self)
self.title_lbl = QLabel("")
self.title_lbl.setObjectName("hint")
self.title_lbl.setStyleSheet("border: none;")
self.value_lbl = QLabel("—")
self.value_lbl.setStyleSheet("border: none; font-size: 20px; font-weight: 700;")
self.sub_lbl = QLabel("")
self.sub_lbl.setObjectName("hint")
self.sub_lbl.setStyleSheet("border: none;")
# Word-wrap the sub-label: a long note (e.g. the "estimated tokens"
# sentence on the cost card) would otherwise report a single-line
# sizeHint hundreds of px wide, forcing its WHOLE grid column open and
# throwing every card in the row out of alignment.
self.sub_lbl.setWordWrap(True)
lay.addWidget(self.title_lbl)
lay.addWidget(self.value_lbl)
lay.addWidget(self.sub_lbl)
def set(self, title: str, value: str, sub: str = "") -> None:
self.title_lbl.setText(title)
self.value_lbl.setText(value)
self.sub_lbl.setText(sub)
class BudgetCard(QFrame):
"""Remaining/Budget box — same card chrome as :class:`StatCard`, plus a
direct budget-entry field. The card is a dumb display: the owning tab
(Dashboard/Monitoring, both share the same ``usage.budget_*`` config) wires
``apply_btn.clicked`` to persist a new budget and refresh, and calls
:meth:`set` with pre-formatted text + whether to render in the ⚠ warn color
(the app turns the remaining balance red past 85% budget used)."""
def __init__(self):
super().__init__()
self.setFrameShape(QFrame.StyledPanel)
self.setStyleSheet(
"QFrame { border: 1px solid rgba(140,146,152,0.35); border-radius: 16px; }")
shadow = QGraphicsDropShadowEffect(self)
shadow.setBlurRadius(18)
shadow.setOffset(0, 3)
shadow.setColor(QColor(0, 0, 0, 60))
self.setGraphicsEffect(shadow)
lay = QVBoxLayout(self)
self.title_lbl = QLabel("")
self.title_lbl.setObjectName("hint")
self.title_lbl.setStyleSheet("border: none;")
self.value_lbl = QLabel("—")
self._value_style = "border: none; font-size: 20px; font-weight: 700;"
self.value_lbl.setStyleSheet(self._value_style)
self.sub_lbl = QLabel("")
self.sub_lbl.setObjectName("hint")
self.sub_lbl.setStyleSheet("border: none;")
# Word-wrap the sub-label: a long note (e.g. the "estimated tokens"
# sentence on the cost card) would otherwise report a single-line
# sizeHint hundreds of px wide, forcing its WHOLE grid column open and
# throwing every card in the row out of alignment.
self.sub_lbl.setWordWrap(True)
lay.addWidget(self.title_lbl)
lay.addWidget(self.value_lbl)
lay.addWidget(self.sub_lbl)
row = QHBoxLayout()
row.setSpacing(4)
self.budget_spin = QDoubleSpinBox()
self.budget_spin.setRange(0, 100_000_000)
self.budget_spin.setDecimals(2)
self.budget_spin.setButtonSymbols(QAbstractSpinBox.NoButtons)
self.apply_btn = QPushButton()
self.apply_btn.setFixedWidth(30)
row.addWidget(self.budget_spin, 1)
row.addWidget(self.apply_btn)
lay.addLayout(row)
def set(self, title: str, value: str, sub: str, warn: bool = False) -> None:
self.title_lbl.setText(title)
self.value_lbl.setText(value)
self.value_lbl.setStyleSheet(
self._value_style + (" color: #E5484D;" if warn else ""))
self.sub_lbl.setText(sub)
def fmt_tokens(n: int) -> str:
if n >= 1_000_000:
return f"{n / 1e6:.2f}M"
if n >= 1_000:
return f"{n / 1e3:.1f}K"
return str(n)
class _WheelGuard(QObject):
"""Swallows wheel events on a value widget unless the user has clicked
into it first (i.e. it has keyboard focus). Without this, scrolling a
Settings/task-editor form accidentally spins whatever combo box or
spin box the cursor happens to pass over, silently changing values."""
def eventFilter(self, obj, event): # noqa: N802
if event.type() == QEvent.Wheel and not obj.hasFocus():
event.ignore()
return True # eat it → the scroll area scrolls instead
return False
_wheel_guard = _WheelGuard()
def guard_wheel(root: QWidget) -> None:
"""Protect every QComboBox / spin box / date-time edit under ``root``:
the mouse wheel only changes their value after an explicit click into
the widget (StrongFocus excludes wheel-acquired focus), otherwise the
wheel scrolls the surrounding form like the user expects."""
targets = root.findChildren(QComboBox) + root.findChildren(QAbstractSpinBox)
for w in targets:
w.setFocusPolicy(Qt.StrongFocus)
w.installEventFilter(_wheel_guard)
class CollapseStrip(QWidget):
"""The slim bar shown in place of a collapsed side panel.
It draws a clear chevron (▸ / ◂) near the top — the expand affordance — over
a thin handle line, and the whole strip is clickable to expand the panel."""
clicked = Signal()
WIDTH = 18 # click target width; wide enough to show the expand arrow
def __init__(self, tooltip: str = "Click to expand", expand_dir: str = "right"):
super().__init__()
self._hover = False
self._dir = "left" if expand_dir == "left" else "right"
self.setFixedWidth(self.WIDTH)
self.setSizePolicy(QSizePolicy.Fixed, QSizePolicy.Expanding)
self.setCursor(Qt.PointingHandCursor)
self.setToolTip(tooltip)
def enterEvent(self, e) -> None: # noqa: N802
self._hover = True
self.update()
super().enterEvent(e)
def leaveEvent(self, e) -> None: # noqa: N802
self._hover = False
self.update()
super().leaveEvent(e)
def mousePressEvent(self, e) -> None: # noqa: N802
if e.button() == Qt.LeftButton:
self.clicked.emit()
super().mousePressEvent(e)
def paintEvent(self, e) -> None: # noqa: N802
p = QPainter(self)
p.setRenderHint(QPainter.Antialiasing)
w = self.width()
accent = QColor(ACCENT) if self._hover else QColor("#8b8d98")
# A small rounded "button" at the top carries the expand arrow so the
# collapsed panel always shows a clear, clickable affordance.
bw = min(w - 2.0, 16.0)
btn = QRectF((w - bw) / 2.0, 6.0, bw, 18.0)
p.setPen(QPen(QColor(139, 144, 150, 130), 1.0))
p.setBrush(QColor(155, 160, 166, 70) if self._hover else QColor(155, 160, 166, 32))
p.drawRoundedRect(btn, 4.0, 4.0)
cx = w / 2.0
cy = btn.center().y()
s = 4.0
pen = QPen(accent, 2.0)
pen.setCapStyle(Qt.RoundCap)
pen.setJoinStyle(Qt.RoundJoin)
p.setPen(pen)
if self._dir == "right": # '›' — expands content to the right
tip_x, base_x = cx + s / 2.0, cx - s / 2.0
else: # '‹' — expands content to the left
tip_x, base_x = cx - s / 2.0, cx + s / 2.0
p.drawLine(QPointF(base_x, cy - s), QPointF(tip_x, cy))
p.drawLine(QPointF(tip_x, cy), QPointF(base_x, cy + s))
# thin handle line below the button
p.setPen(Qt.NoPen)
p.setBrush(QColor(155, 160, 166, 90))
line_w = 2.0
x = (w - line_w) / 2.0
ltop = btn.bottom() + 6.0
lbottom = max(ltop, self.height() - 10.0)
p.drawRoundedRect(QRectF(x, ltop, line_w, lbottom - ltop), 1.0, 1.0)
p.end()
class PlanSection(QWidget):
"""A collapsible checklist of the current message's plan steps with live
line-icon status markers (pending dot · running play · done check · error
close). Hidden until it has steps; updated in place as the agent calls
``update_plan``."""
_COLORS = {STEP_RUNNING: ACCENT, STEP_DONE: "#6fe3a4", STEP_ERROR: "#ef6368"}
@staticmethod
def _step_icon(status: str):
if status == STEP_RUNNING:
return icon("play", color=DOT_BLUE)
if status == STEP_DONE:
return icon("check", color=DOT_GREEN)
if status == STEP_ERROR:
return icon("close", color=DOT_RED)
return dot_icon(DOT_GREY) # pending
def __init__(self, title: str = "Plan", max_height: int = 150):
super().__init__()
self._title = title
self._count = 0
lay = QVBoxLayout(self)
lay.setContentsMargins(0, 0, 0, 0)
lay.setSpacing(2)
self.header = QPushButton()
self.header.setCheckable(True)
self.header.setChecked(True)
self.header.setStyleSheet("text-align:left; font-weight:600;")
self.header.toggled.connect(self._toggle)
lay.addWidget(self.header)
self.list = QListWidget()
self.list.setMaximumHeight(max_height) # scrolls when longer
lay.addWidget(self.list)
self.setVisible(False)
self._update_header()
def set_steps(self, steps) -> None:
"""Replace the checklist with ``[{title, status}]`` (the agent sends the FULL
list each update, so we rebuild in place)."""
self.list.clear()
self._count = 0
for s in steps or []:
title = str((s or {}).get("title", "")).strip()
if not title:
continue
status = str((s or {}).get("status", STEP_PENDING)).strip().lower()
item = QListWidgetItem(self._step_icon(status), f" {title}")
color = self._COLORS.get(status)
if color:
item.setForeground(QColor(color))
self.list.addItem(item)
self._count += 1
self.setVisible(self._count > 0)
if self._count and not self.header.isChecked():
self.header.setChecked(True)
self.list.setVisible(self.header.isChecked())
self._update_header()
def clear(self) -> None:
self.list.clear()
self._count = 0
self.setVisible(False)
self._update_header()
def set_title(self, title: str) -> None:
"""Update the header label (for live language switching)."""
self._title = title
self._update_header()
def _toggle(self, on: bool) -> None:
self.list.setVisible(on)
self._update_header()
def _update_header(self) -> None:
arrow = "▾" if self.header.isChecked() else "▸"
self.header.setText(f"{arrow} {self._title} ({self._count})")
class CollapsibleSection(QWidget):
"""A pull-down header + a scrollable list. Hidden until it has items."""
activated = Signal(str) # emits the path of a clicked item
def __init__(self, title: str, max_height: int = 130):
super().__init__()
self._title = title
self._paths: list[str] = []
lay = QVBoxLayout(self)
lay.setContentsMargins(0, 0, 0, 0)
lay.setSpacing(2)
self.header = QPushButton()
self.header.setCheckable(True)
self.header.setChecked(False)
self.header.setStyleSheet("text-align:left; font-weight:600;")
self.header.toggled.connect(self._toggle)
lay.addWidget(self.header)
self.list = QListWidget()
self.list.setMaximumHeight(max_height) # scrolls when longer
self.list.setVisible(False)
self.list.itemActivated.connect(self._emit)
self.list.itemClicked.connect(self._emit)
lay.addWidget(self.list)
self.setVisible(False)
self._update_header()
def add(self, path: str) -> None:
if not path or path in self._paths:
return
self._paths.append(path)
item = QListWidgetItem(Path(path).name)
item.setData(Qt.UserRole, path)
item.setToolTip(path)
self.list.addItem(item)
self.setVisible(True)
# Auto-open so added / restored files are visible without a click.
if not self.header.isChecked():
self.header.setChecked(True)
self._update_header()
def remove(self, path: str) -> None:
if path not in self._paths:
return
i = self._paths.index(path)
self._paths.pop(i)
item = self.list.takeItem(i)
del item
self.setVisible(bool(self._paths))
self._update_header()
def paths(self) -> list[str]:
return list(self._paths)
def clear(self) -> None:
self._paths.clear()
self.list.clear()
self.setVisible(False)
self._update_header()
def set_title(self, title: str) -> None:
"""Update the header label (for live language switching)."""
self._title = title
self._update_header()
def _toggle(self, on: bool) -> None:
self.list.setVisible(on)
self._update_header()
def _update_header(self) -> None:
arrow = "▾" if self.header.isChecked() else "▸"
self.header.setText(f"{arrow} {self._title} ({len(self._paths)})")
def _emit(self, item: QListWidgetItem) -> None:
path = item.data(Qt.UserRole)
if path:
self.activated.emit(path)