"""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, QHBoxLayout, QLabel, QListWidget, QListWidgetItem, QPushButton, QSizePolicy, QVBoxLayout, QWidget, ) from ..core.flows import STEP_DONE, STEP_ERROR, STEP_PENDING, STEP_RUNNING from ..theme import current_palette from .icons import DOT_BLUE, DOT_GREEN, DOT_GREY, DOT_RED, dot_icon, icon def _style_card(frame: QFrame) -> None: """Give a stat/budget card its surface. Flat by design: the raised surface plus a hairline is what separates it from the page — the old drop shadow made a grid of these look like it was hovering off the screen.""" p = current_palette() frame.setStyleSheet( f"QFrame {{ background: {p.surface}; border: 1px solid {p.border};" f" border-radius: {p.radius_lg}px; }}") 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.NoFrame) _style_card(self) 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: 22px; font-weight: 600;") 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.NoFrame) _style_card(self) 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: 22px; font-weight: 600;" 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 + (f" color: {current_palette().danger};" 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() tok = current_palette() accent = QColor(tok.accent) if self._hover else QColor(tok.text_faint) # 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(tok.border_strong), 1.0)) p.setBrush(QColor(tok.hover if self._hover else tok.surface)) p.drawRoundedRect(btn, float(tok.radius_sm), float(tok.radius_sm)) 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(tok.border_strong)) 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``.""" @staticmethod def _step_color(status: str) -> str | None: """Row text colour per step status; None leaves the default. Resolved per call so it follows a live theme switch.""" p = current_palette() return {STEP_RUNNING: p.accent, STEP_DONE: p.success, STEP_ERROR: p.danger}.get(status) @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._step_color(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)