chore(repo): initialize Cowork Local Gitea repository
CI / test (push) Canceled after 0s

This commit is contained in:
thanhnv
2026-08-09 20:12:05 +07:00
commit 414eaddca3
192 changed files with 48160 additions and 0 deletions
+386
View File
@@ -0,0 +1,386 @@
"""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)