Implements the redesign from docs/ui-audit.html. Rearrangement only — no
feature was removed; every control that moved kept its handler, and the
gates that used to hide things now grey them out instead.
Navigation
* The rail is one flat list: the five Workspace sub-views sit at the top
level instead of behind an accordion, with Dashboard/Monitoring pinned
at the foot and Settings below them.
* Cowork and GraphRAG stay listed and greyed while no project is
selected, rather than vanishing and resizing the menu under the user.
* Monitoring keeps its eight sub-views in its own tab strip (unhidden)
instead of doubling the rail's length.
* _goto now moves the highlight itself, fixing a long-standing bug where
programmatic navigation left the rail pointing at the previous screen.
* Rail header gained the project picker and "New chat"; RECENTS lists the
active project's threads. Both are second views of existing state — the
Cowork toolbar button and the full History panel are untouched.
* Provider / language / theme moved from the top bar to an account row at
the foot of the rail (same widgets, same signals).
Screens
* Co4E: the flow tab strip is gone (per the design); Flow Status became a
toolbar toggle with its own way back, and the three icon-only tabs became
four labelled, foldable sections in one column. One flow open at a time
is the one capability this costs; background runs are unaffected.
* Dashboard: header split into two rows; cost promoted to a hero card.
* Monitoring Overview: one scrolling column of titled sections; the model
price table got its own full-width section instead of sharing a row with
the CPU meters.
* Settings and Task editor gained a section index down the left.
* Help dock: 84x64 launcher + chevron became one 26px dot that expands to
a labelled pill on hover; "hide to the edge" moved into the panel's menu.
Layout
* The window's minimum width dropped from 1453px to 768px. The main cause
was a QTabWidget taking its minimum from the widest page even when that
page is hidden, so Co4E was forcing Project and Cowork wide.
* Secondary panes fold themselves on a narrow window and restore when it
grows, never overriding a fold the user made.
* The long dialogs no longer scroll sideways at any font size.
Verification: tools/check_*.py build a real MainWindow offscreen against a
copy of ~/.cowork_local with the schedulers no-oped. check_design_parity.py
reads its checklist straight from the audit page's own proposals.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
518 lines
19 KiB
Python
518 lines
19 KiB
Python
"""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)
|
||
|
||
def as_hero(self) -> "StatCard":
|
||
"""Make this the headline card: bigger number, accent colour.
|
||
|
||
Used for the one figure a screen is really about (Dashboard's total
|
||
cost), so a row of otherwise identical tiles has a clear first read.
|
||
"""
|
||
from ..theme import current_palette
|
||
p = current_palette()
|
||
self.value_lbl.setStyleSheet(
|
||
f"border: none; font-size: 34px; font-weight: 700; color: {p.accent};")
|
||
self.setObjectName("heroCard")
|
||
return self
|
||
|
||
|
||
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 _NarrowGuard(QObject):
|
||
"""Calls back when the WINDOW crosses a width threshold.
|
||
|
||
Watching the widget's own width does not work: a pane whose minimum width is
|
||
larger than the space available never reports being narrow — it just gets
|
||
clipped, which is the very problem being solved. The window always knows its
|
||
real size, so that is what gets watched.
|
||
|
||
A fold the user did by hand is never undone: auto-expand only reverses an
|
||
auto-collapse.
|
||
"""
|
||
|
||
def __init__(self, owner: QWidget, threshold: int, apply):
|
||
super().__init__(owner)
|
||
self._owner = owner
|
||
self._threshold = threshold
|
||
self._apply = apply
|
||
self._auto = False # True while WE are the ones holding it folded
|
||
self._window = None
|
||
|
||
def attach(self) -> None:
|
||
win = self._owner.window()
|
||
if win is not None and win is not self._owner and win is not self._window:
|
||
win.installEventFilter(self)
|
||
self._window = win
|
||
self.check()
|
||
|
||
def eventFilter(self, obj, ev): # noqa: N802 - Qt override
|
||
if ev.type() == QEvent.Resize and obj is self._window:
|
||
self.check()
|
||
return super().eventFilter(obj, ev)
|
||
|
||
def check(self) -> None:
|
||
win = self._owner.window()
|
||
width = win.width() if win is not None else self._owner.width()
|
||
narrow = width < self._threshold
|
||
if narrow == self._auto:
|
||
return
|
||
self._auto = narrow
|
||
self._apply(narrow)
|
||
|
||
|
||
def narrow_guard(owner: QWidget, threshold: int, apply):
|
||
"""Fold `owner`'s secondary panes below `threshold` px of window width.
|
||
|
||
``apply(narrow: bool)`` does the folding. Call ``.attach()`` from showEvent.
|
||
"""
|
||
return _NarrowGuard(owner, threshold, apply)
|
||
|
||
|
||
def section_index(scroll, sections, width: int = 260):
|
||
"""A clickable table of contents for a long scrolling dialog.
|
||
|
||
``sections`` is [(label, anchor_widget)]. Clicking a row scrolls its anchor
|
||
into view; scrolling the dialog moves the highlight back. Purely navigation:
|
||
every field stays exactly where it was, in the same one scrolling column —
|
||
Settings and the Task editor were five stacked group boxes deep with no way
|
||
to tell what was further down.
|
||
|
||
Returns the QListWidget so the caller can place it.
|
||
"""
|
||
from PySide6.QtWidgets import QListWidget, QListWidgetItem
|
||
|
||
index = QListWidget()
|
||
index.setObjectName("sectionIndex")
|
||
index.setFrameShape(QListWidget.NoFrame)
|
||
# Long section names (and 125%/150% display scaling) used to push a
|
||
# horizontal scrollbar into this list. It elides instead, with the full
|
||
# name on hover.
|
||
index.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
|
||
index.setTextElideMode(Qt.ElideRight)
|
||
index.setWordWrap(False)
|
||
for label, anchor in sections:
|
||
item = QListWidgetItem(label)
|
||
item.setToolTip(label)
|
||
item.setData(Qt.UserRole, anchor)
|
||
index.addItem(item)
|
||
index.setCurrentRow(0)
|
||
# Wide enough for the longest name at the CURRENT font — so the width grows
|
||
# with display scaling instead of eliding everything — but capped so it
|
||
# never eats the form beside it. `width` is that cap, not a fixed size.
|
||
natural = max(index.fontMetrics().horizontalAdvance(lab) for lab, _a in sections) + 36
|
||
index.setFixedWidth(max(120, min(width, natural)))
|
||
|
||
def _jump(item):
|
||
anchor = item.data(Qt.UserRole)
|
||
if anchor is not None:
|
||
# Scroll so the section's top edge lands at the top of the viewport,
|
||
# rather than merely "somewhere visible".
|
||
bar = scroll.verticalScrollBar()
|
||
top = anchor.mapTo(scroll.widget(), anchor.rect().topLeft()).y()
|
||
bar.setValue(min(top, bar.maximum()))
|
||
|
||
index.itemClicked.connect(_jump)
|
||
|
||
def _follow(value: int):
|
||
"""Highlight the last section whose top has passed the viewport top."""
|
||
row = 0
|
||
for i in range(index.count()):
|
||
anchor = index.item(i).data(Qt.UserRole)
|
||
if anchor is None:
|
||
continue
|
||
top = anchor.mapTo(scroll.widget(), anchor.rect().topLeft()).y()
|
||
if top <= value + 4:
|
||
row = i
|
||
if index.currentRow() != row:
|
||
blocked = index.blockSignals(True)
|
||
index.setCurrentRow(row)
|
||
index.blockSignals(blocked)
|
||
|
||
scroll.verticalScrollBar().valueChanged.connect(_follow)
|
||
return index
|
||
|
||
|
||
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)
|