From e2d020ef7b0e99357935449acea30ebc41de9972 Mon Sep 17 00:00:00 2001 From: Lam Hoang Van Date: Wed, 19 Aug 2026 16:58:23 +0900 Subject: [PATCH] Update Screen: Cong cu, settings, them/Chinh sua task Co-Authored-By: Claude Sonnet 5 --- i18n.py | 11 +- theme.py | 65 +++++++- ui/connectors_panel.py | 317 +++++++++++++++++++++---------------- ui/ext_connector_dialog.py | 35 +++- ui/task_editor_dialog.py | 37 +++-- ui/tools_admin_tab.py | 183 +++++++++++++-------- ui/widgets.py | 100 +++++++++++- 7 files changed, 509 insertions(+), 239 deletions(-) diff --git a/i18n.py b/i18n.py index 0ec162d..4e23960 100644 --- a/i18n.py +++ b/i18n.py @@ -1799,6 +1799,7 @@ STRINGS: Dict[str, Dict[str, str]] = { "vi": "Xóa connector \"{name}\"?"}, "ext.add_title": {"en": "Add connector", "ja": "コネクタを追加", "vi": "Thêm connector"}, "ext.edit_title": {"en": "Edit connector", "ja": "コネクタを編集", "vi": "Sửa connector"}, + "ext.category_label": {"en": "Category", "ja": "カテゴリ", "vi": "Nhóm"}, "ext.preset_label": {"en": "App", "ja": "アプリ", "vi": "Ứng dụng"}, "ext.preset_custom": {"en": "(Custom…)", "ja": "(カスタム…)", "vi": "(Tuỳ chỉnh…)"}, "ext.name_label": {"en": "Display name", "ja": "表示名", "vi": "Tên hiển thị"}, @@ -2493,9 +2494,6 @@ STRINGS: Dict[str, Dict[str, str]] = { "icons_admin.select_custom": {"en": "Select a custom icon to delete.", "ja": "削除するカスタムアイコンを選択してください。", "vi": "Hãy chọn một icon tùy chỉnh để xóa."}, - "tools_admin.col_name": {"en": "Tool", "ja": "ツール", "vi": "Tool"}, - "tools_admin.col_desc": {"en": "Description", "ja": "説明", "vi": "Mô tả"}, - "tools_admin.col_enabled": {"en": "Enabled", "ja": "有効", "vi": "Bật"}, "tools_admin.jira_note": { "en": "Jira connection setup moved to the Connector tab → set it up there; here you only turn " "the jira_search / jira_get_issue tools on or off.", @@ -2530,10 +2528,9 @@ STRINGS: Dict[str, Dict[str, str]] = { "en": "Double-click to connect Jira (paste any Jira link — no per-request setup after that).", "ja": "ダブルクリックで Jira に接続(Jira リンクを貼るだけ、以降は設定不要)。", "vi": "Nhấp đúp để kết nối Jira (dán bất kỳ link Jira nào — sau đó không cần thiết lập gì thêm)."}, - "connectors.dbl_configure": { - "en": "Double-click a connector to configure it.", - "ja": "コネクタをダブルクリックして設定します。", - "vi": "Nhấp đúp vào một connector để thiết lập."}, + "connectors.builtin_auto": { + "en": "Built-in, connects automatically", "ja": "組み込み、自動接続", + "vi": "Tích hợp, tự kết nối"}, "connectors.connect_external": { "en": "Connect to external connectors", "ja": "外部コネクタに接続する", diff --git a/theme.py b/theme.py index b019ab4..4f2b279 100644 --- a/theme.py +++ b/theme.py @@ -38,6 +38,42 @@ from dataclasses import dataclass, asdict from string import Template +def _chevron_asset(direction: str, color: str) -> str: + """Render (and cache on disk) a small chevron PNG for QComboBox/QSpinBox + arrow subcontrols. QSS's ``image:`` property only accepts a resource or + file path, never a live QPixmap — and once ``::drop-down``/``::up-button``/ + ``::down-button`` are styled at all, Qt stops drawing its own built-in + arrow, so without this the controls show no affordance whatsoever.""" + import hashlib + import tempfile + from pathlib import Path + + key = hashlib.md5(f"{direction}-{color}".encode()).hexdigest()[:10] + path = Path(tempfile.gettempdir()) / "cowork_local_theme" / f"chevron_{key}.png" + if not path.exists(): + path.parent.mkdir(parents=True, exist_ok=True) + from PySide6.QtCore import QPointF, Qt + from PySide6.QtGui import QColor, QPainter, QPixmap + + size = 12 + pm = QPixmap(size, size) + pm.fill(Qt.transparent) + p = QPainter(pm) + p.setRenderHint(QPainter.Antialiasing) + pen = p.pen() + pen.setColor(QColor(color)) + pen.setWidthF(1.6) + pen.setCapStyle(Qt.RoundCap) + pen.setJoinStyle(Qt.RoundJoin) + p.setPen(pen) + pts = ([QPointF(2.5, 4.5), QPointF(6, 8), QPointF(9.5, 4.5)] if direction == "down" + else [QPointF(2.5, 7.5), QPointF(6, 4), QPointF(9.5, 7.5)]) + p.drawPolyline(pts) + p.end() + pm.save(str(path)) + return path.as_posix() + + @dataclass(frozen=True) class Palette: """Every colour and shape value the interface is allowed to use.""" @@ -574,6 +610,27 @@ QSpinBox:focus, QDoubleSpinBox:focus { border: 1px solid $focus_ring; } QLineEdit:disabled, QPlainTextEdit:disabled, QTextEdit:disabled { background: $surface; color: $text_disabled; } +/* Explicit up/down button geometry: once ANY spin-box subcontrol is styled, + Qt uses exactly this rect for both painting AND hit-testing, so the + clickable area can no longer drift from what's drawn (the previous + unstyled default arrows misaligned their own click region at 125%/150% + Windows display scaling — this pins both to the same rect instead). */ +QSpinBox::up-button, QDoubleSpinBox::up-button { + subcontrol-origin: border; subcontrol-position: top right; + width: 18px; height: 15px; border: none; background: transparent; +} +QSpinBox::down-button, QDoubleSpinBox::down-button { + subcontrol-origin: border; subcontrol-position: bottom right; + width: 18px; height: 15px; border: none; background: transparent; +} +QSpinBox::up-button:hover, QDoubleSpinBox::up-button:hover, +QSpinBox::down-button:hover, QDoubleSpinBox::down-button:hover { background: $hover; } +QSpinBox::up-button:pressed, QDoubleSpinBox::up-button:pressed, +QSpinBox::down-button:pressed, QDoubleSpinBox::down-button:pressed { background: $active; } +QSpinBox::up-arrow, QDoubleSpinBox::up-arrow { image: url($chevron_up); width: 9px; height: 9px; } +QSpinBox::down-arrow, QDoubleSpinBox::down-arrow { image: url($chevron_down); width: 9px; height: 9px; } +QSpinBox::up-arrow:disabled, QSpinBox::down-arrow:disabled, +QDoubleSpinBox::up-arrow:disabled, QDoubleSpinBox::down-arrow:disabled { image: none; } QTreeView::item, QListView::item, QTableView::item { padding: 4px 3px; border-radius: ${radius_sm}px; } QTreeView::item:hover, QListView::item:hover { background: $hover; } @@ -639,6 +696,8 @@ QComboBox:hover { background: $hover; } QComboBox:focus { border-color: $focus_ring; } QComboBox:disabled { color: $text_disabled; background: $surface; border-color: $border; } QComboBox::drop-down { border: none; width: 20px; } +QComboBox::down-arrow { image: url($chevron_down); width: 10px; height: 10px; } +QComboBox::down-arrow:disabled { image: none; } QComboBox QAbstractItemView { background: $overlay; color: $text; border: 1px solid $border_strong; border-radius: ${radius}px; padding: 4px; outline: none; @@ -825,7 +884,11 @@ def current_palette() -> Palette: def stylesheet(theme: str) -> str: """The application-wide Qt style sheet for ``theme``.""" - return _TEMPLATE.substitute(asdict(palette(theme))) + p = palette(theme) + values = asdict(p) + values["chevron_down"] = _chevron_asset("down", p.text_muted) + values["chevron_up"] = _chevron_asset("up", p.text_muted) + return _TEMPLATE.substitute(values) def role_colors(theme: str) -> dict[str, str]: diff --git a/ui/connectors_panel.py b/ui/connectors_panel.py index 1c9fd4d..77f9bd7 100644 --- a/ui/connectors_panel.py +++ b/ui/connectors_panel.py @@ -1,18 +1,20 @@ """Connectors (MCP / REST API) management — the setup UI. -Lives in Monitoring → Tools → "Connector" sub-tab (moved out of Settings). A -tree of the four categories (CAD / CAE / MS365 / Other); each connector has an -Enabled checkbox and can be added / edited / deleted (MCP-stdio or REST-API, -via ExtConnectorEditDialog). Built-in connectors (MS365 OneDrive / SharePoint, -and Jira under "Other") appear as rows in the tree with their checkbox bound to -config; double-clicking one opens its setup — nothing spills outside the tree. +Lives in Monitoring → Tools → "Connector" sub-tab (moved out of Settings). +Grouped by the four real categories (CAD / CAE / MS365 / Other): each is a +header (icon + name + the software it covers) above a left-aligned row of +cards, one per real connector, each with its own on/off switch — user-added +connectors (MCP-stdio or REST-API, via ExtConnectorEditDialog) also get +✎ Sửa/🗑 Xóa; the built-in ones (MS365 OneDrive/SharePoint, Jira under +"Other") only get what they actually support (Jira: ✎ Sửa only, opens its own +setup dialog; OneDrive/SharePoint: neither, they only toggle). """ from __future__ import annotations from PySide6.QtCore import Qt from PySide6.QtWidgets import ( - QCheckBox, QDialog, QFormLayout, QHBoxLayout, QLabel, QLineEdit, QMessageBox, - QPushButton, QTreeWidget, QTreeWidgetItem, QVBoxLayout, QWidget, + QDialog, QFormLayout, QFrame, QHBoxLayout, QLabel, QLineEdit, QMessageBox, + QPushButton, QScrollArea, QVBoxLayout, QWidget, ) from ..core.ext_connectors import CATEGORIES as EXT_CATEGORIES @@ -21,6 +23,7 @@ from ..i18n import on_language_changed, tr from ..state import AppContext from .ext_connector_dialog import ExtConnectorEditDialog from .icons import icon +from .widgets import FlowLayout, ToggleSwitch, enable_height_for_width, style_card class JiraConnectDialog(QDialog): @@ -130,174 +133,219 @@ class ConnectorsPanel(QWidget): # Master switch: connect to external connectors at all (default ON). # Off = the agent connects to NO external connector/MCP (see # AppContext.build_mcp_tools), regardless of the per-connector checks below. - self.connect_external_chk = QCheckBox(tr("connectors.connect_external")) - self.connect_external_chk.setChecked(self.ctx.config.connect_external) - self.connect_external_chk.setToolTip(tr("connectors.connect_external_tooltip")) - self.connect_external_chk.toggled.connect(self._on_connect_external_toggled) - lay.addWidget(self.connect_external_chk) + self.connect_external_sw = ToggleSwitch(tr("connectors.connect_external")) + self.connect_external_sw.setChecked(self.ctx.config.connect_external) + self.connect_external_sw.setToolTip(tr("connectors.connect_external_tooltip")) + self.connect_external_sw.toggled.connect(self._on_connect_external_toggled) + lay.addWidget(self.connect_external_sw) hint = QLabel(tr("settings.ext_hint")) hint.setObjectName("hint") hint.setWordWrap(True) lay.addWidget(hint) - self.ext_tree = QTreeWidget() - self.ext_tree.setHeaderHidden(True) - self.ext_tree.itemChanged.connect(self._on_ext_check) - self.ext_tree.itemDoubleClicked.connect(lambda *_: self._ext_edit()) - lay.addWidget(self.ext_tree, 1) + # Each category (CAD/CAE/MS365/Other) is a header + a left-aligned, + # wrapping row of cards — one per real connector — instead of a tree + # the admin had to expand to see what was inside. + self._cat_scroll = QScrollArea() + self._cat_scroll.setWidgetResizable(True) + self._cat_scroll.setFrameShape(QScrollArea.NoFrame) + cat_host = QWidget() + enable_height_for_width(cat_host) # holds several height-for-width sections — see FlowLayout + self._cat_lay = QVBoxLayout(cat_host) + self._cat_lay.setContentsMargins(0, 4, 0, 4) + self._cat_lay.setSpacing(10) + self._cat_scroll.setWidget(cat_host) + lay.addWidget(self._cat_scroll, 1) - self.dbl_hint = QLabel(tr("connectors.dbl_configure")) - self.dbl_hint.setObjectName("hint") - lay.addWidget(self.dbl_hint) - - row = QHBoxLayout() self.add_btn = QPushButton(tr("settings.ext_add_btn")) self.add_btn.setIcon(icon("plus")) self.add_btn.setObjectName("primary") + self.add_btn.setCursor(Qt.PointingHandCursor) self.add_btn.clicked.connect(self._ext_add) - self.edit_btn = QPushButton(tr("settings.ext_edit_btn")) - self.edit_btn.setIcon(icon("edit")) - self.edit_btn.clicked.connect(self._ext_edit) - self.del_btn = QPushButton(tr("settings.ext_delete_btn")) - self.del_btn.setIcon(icon("trash")) - self.del_btn.clicked.connect(self._ext_delete) - row.addWidget(self.add_btn) - row.addWidget(self.edit_btn) - row.addWidget(self.del_btn) - row.addStretch(1) - lay.addLayout(row) + add_row = QHBoxLayout() + add_row.addWidget(self.add_btn) + add_row.addStretch(1) + lay.addLayout(add_row) self.ms365_local_status = QLabel() self.ms365_local_status.setObjectName("hint") self.ms365_local_status.setWordWrap(True) lay.addWidget(self.ms365_local_status) + # on_language_changed() below already invokes _retranslate() once + # immediately (see i18n.py), which itself calls _reload_connectors() — + # calling it again here would rebuild the category cards twice + # back-to-back with no event-loop turn in between, so the first pass's + # widgets are only QUEUED for deleteLater() (not yet gone) when the + # second pass adds new ones on top: the two rows visually overlap + # (the exact bug agents_admin_tab.py hit the same way). self._refresh_ms365_local_status() - self._reload_ext_tree() on_language_changed(self._retranslate) # ---- rendering ------------------------------------------------------------ - def _reload_ext_tree(self) -> None: - self.ext_tree.blockSignals(True) - self.ext_tree.clear() - ext = self.ctx.config.ext_connectors + def _clear_categories(self) -> None: + while self._cat_lay.count(): + item = self._cat_lay.takeAt(0) + w = item.widget() + if w is not None: + w.deleteLater() + + def _reload_connectors(self) -> None: + self._clear_categories() for cat in EXT_CATEGORIES: - cat_item = QTreeWidgetItem([self._EXT_CATEGORY_LABELS.get(cat, cat)]) - cat_item.setIcon(0, icon(self._EXT_CATEGORY_ICONS.get(cat, "plug"))) - cat_item.setFlags(Qt.ItemIsEnabled | Qt.ItemIsSelectable) - cat_item.setData(0, Qt.UserRole, ("category", cat)) - self.ext_tree.addTopLevelItem(cat_item) - if cat == "ms365": - conns = self.ctx.config.ms365.get("connectors", {}) - for key, label in self._MS365_BUILTIN_LABELS.items(): - b = QTreeWidgetItem([f"{label} — {tr('ext.mode_builtin')}"]) - b.setFlags(b.flags() | Qt.ItemIsUserCheckable) - b.setCheckState(0, Qt.Checked if conns.get(key) else Qt.Unchecked) - b.setData(0, Qt.UserRole, ("ms365_builtin", "ms365", key)) - cat_item.addChild(b) - for idx, entry in enumerate(ext.get(cat, [])): - mode_label = tr("ext.mode_mcp") if entry.get("mode") == "mcp_stdio" else tr("ext.mode_rest") - child = QTreeWidgetItem([f"{entry.get('name', '')} — {mode_label}"]) - child.setFlags(child.flags() | Qt.ItemIsUserCheckable) - child.setCheckState(0, Qt.Checked if entry.get("enabled") else Qt.Unchecked) - child.setData(0, Qt.UserRole, ("connector", cat, idx)) - cat_item.addChild(child) - if cat == "other": - # Jira is a built-in "Other" connector (like OneDrive under MS365): - # checkbox = enabled; double-click opens its minimal setup dialog. - jira = self.ctx.config.data.get("jira", {}) - configured = bool(jira.get("base_url") and jira.get("email") - and jira.get("api_token")) - jstate = tr("connectors.jira_connected") if configured else tr("connectors.jira_not_set") - jrow = QTreeWidgetItem([f"Jira — {tr('ext.mode_builtin')} · {jstate}"]) - jrow.setIcon(0, icon("link")) - jrow.setFlags(jrow.flags() | Qt.ItemIsUserCheckable) - on = configured and jira.get("enabled", True) - jrow.setCheckState(0, Qt.Checked if on else Qt.Unchecked) - jrow.setToolTip(0, tr("connectors.jira_setup_hint")) - jrow.setData(0, Qt.UserRole, ("jira_builtin", "other")) - cat_item.addChild(jrow) - cat_item.setExpanded(True) - self.ext_tree.blockSignals(False) + self._cat_lay.addWidget(self._category_section(cat)) - def _on_ext_check(self, item: QTreeWidgetItem, _col: int) -> None: - data = item.data(0, Qt.UserRole) - if data and data[0] == "ms365_builtin": - self.ctx.config.ms365.setdefault("connectors", {})[data[2]] = ( - item.checkState(0) == Qt.Checked) - self.ctx.save() - return - if data and data[0] == "jira_builtin": - self.ctx.config.data.setdefault("jira", {})["enabled"] = ( - item.checkState(0) == Qt.Checked) - self.ctx.save() - return - if not data or data[0] != "connector": - return - _, cat, idx = data - entries = self.ctx.config.ext_connectors.get(cat, []) - if 0 <= idx < len(entries): - entries[idx]["enabled"] = item.checkState(0) == Qt.Checked - self.ctx.save() + def _category_section(self, cat: str) -> QWidget: + section = QWidget() + enable_height_for_width(section) # this section wraps a FlowLayout row — see FlowLayout + sl = QVBoxLayout(section) + sl.setContentsMargins(0, 0, 0, 0) + sl.setSpacing(6) - def _current_ext_category(self) -> str: - item = self.ext_tree.currentItem() - data = item.data(0, Qt.UserRole) if item else None - return data[1] if data else EXT_CATEGORIES[0] + hdr = QHBoxLayout() + hdr.setSpacing(6) + icon_lbl = QLabel() + icon_lbl.setPixmap(icon(self._EXT_CATEGORY_ICONS.get(cat, "plug"), size=18).pixmap(18, 18)) + hdr.addWidget(icon_lbl) + name, _, subtitle = self._EXT_CATEGORY_LABELS.get(cat, cat).partition(" (") + name_lbl = QLabel(name) + name_lbl.setStyleSheet("font-weight:700;") + hdr.addWidget(name_lbl) + if subtitle: + sub_lbl = QLabel("(" + subtitle) + sub_lbl.setObjectName("hint") + hdr.addWidget(sub_lbl) + hdr.addStretch(1) + sl.addLayout(hdr) - def _current_ext_connector(self): - item = self.ext_tree.currentItem() - data = item.data(0, Qt.UserRole) if item else None - if not data or data[0] != "connector": - return None - _, cat, idx = data - entries = self.ctx.config.ext_connectors.get(cat, []) - return (cat, entries[idx]) if 0 <= idx < len(entries) else None + flow_host = QWidget() + flow = FlowLayout(flow_host, margin=0, h_spacing=10, v_spacing=10) + + if cat == "ms365": + conns = self.ctx.config.ms365.get("connectors", {}) + for key, label in self._MS365_BUILTIN_LABELS.items(): + flow.addWidget(self._connector_card( + label, tr("connectors.builtin_auto"), bool(conns.get(key)), + lambda on, k=key: self._toggle_ms365_builtin(k, on))) + + for entry in self.ctx.config.ext_connectors.get(cat, []): + mode_label = tr("ext.mode_mcp") if entry.get("mode") == "mcp_stdio" else tr("ext.mode_rest") + flow.addWidget(self._connector_card( + entry.get("name", ""), mode_label, bool(entry.get("enabled")), + lambda on, e=entry: self._toggle_ext_entry(e, on), + # QPushButton.clicked emits a `checked` bool — a lambda whose + # ONLY parameter is a defaulted capture (`e=entry`) looks like + # it accepts that bool, so Qt hands it the click state instead + # of using the default, silently replacing the captured dict + # with False. An explicit leading `checked=False` soaks up the + # signal's argument so `e` keeps the entry it was defined with. + edit_cb=lambda checked=False, e=entry: self._edit_ext_entry(cat, e), + delete_cb=lambda checked=False, e=entry: self._delete_ext_entry(cat, e))) + + if cat == "other": + # Jira is a built-in "Other" connector (like OneDrive under MS365): + # switch = enabled; ✎ Sửa opens its own minimal setup dialog — no + # 🗑 Xóa, same as OneDrive/SharePoint have neither (nothing to delete). + jira = self.ctx.config.data.get("jira", {}) + configured = bool(jira.get("base_url") and jira.get("email") and jira.get("api_token")) + jstate = tr("connectors.jira_connected") if configured else tr("connectors.jira_not_set") + flow.addWidget(self._connector_card( + "Jira", f"{tr('ext.mode_builtin').capitalize()} · {jstate}", + configured and jira.get("enabled", True), self._toggle_jira, + edit_cb=self._open_jira_dialog)) + + sl.addWidget(flow_host) + return section + + def _connector_card(self, title: str, subtitle: str, checked: bool, on_toggle, + edit_cb=None, delete_cb=None) -> QWidget: + card = QFrame() + card.setFrameShape(QFrame.NoFrame) + style_card(card) + lay = QVBoxLayout(card) + lay.setContentsMargins(10, 8, 10, 8) + lay.setSpacing(4) + + hdr = QHBoxLayout() + name_lbl = QLabel(title) + name_lbl.setStyleSheet("font-weight:700; border: none;") + hdr.addWidget(name_lbl) + hdr.addStretch(1) + sw = ToggleSwitch() + sw.setChecked(checked) + sw.toggled.connect(on_toggle) + hdr.addWidget(sw) + lay.addLayout(hdr) + + sub_lbl = QLabel(subtitle) + sub_lbl.setObjectName("hint") + sub_lbl.setStyleSheet("border: none;") + lay.addWidget(sub_lbl) + + if edit_cb is not None or delete_cb is not None: + actions = QHBoxLayout() + actions.setContentsMargins(0, 2, 0, 0) + actions.setSpacing(2) + if edit_cb is not None: + b = QPushButton(tr("settings.ext_edit_btn")) + b.setIcon(icon("edit")) + b.setFlat(True) + b.setCursor(Qt.PointingHandCursor) + b.clicked.connect(edit_cb) + actions.addWidget(b) + if delete_cb is not None: + b = QPushButton(tr("settings.ext_delete_btn")) + b.setIcon(icon("trash")) + b.setFlat(True) + b.setCursor(Qt.PointingHandCursor) + b.clicked.connect(delete_cb) + actions.addWidget(b) + actions.addStretch(1) + lay.addLayout(actions) + + return card + + def _toggle_ms365_builtin(self, key: str, checked: bool) -> None: + self.ctx.config.ms365.setdefault("connectors", {})[key] = checked + self.ctx.save() + + def _toggle_jira(self, checked: bool) -> None: + self.ctx.config.data.setdefault("jira", {})["enabled"] = checked + self.ctx.save() + + def _toggle_ext_entry(self, entry: dict, checked: bool) -> None: + entry["enabled"] = checked + self.ctx.save() # ---- CRUD ----------------------------------------------------------------- def _ext_add(self) -> None: - dlg = ExtConnectorEditDialog(self, category=self._current_ext_category()) + dlg = ExtConnectorEditDialog(self, category=EXT_CATEGORIES[0]) if dlg.exec(): entry = dlg.result_connector() self.ctx.config.ext_connectors.setdefault(entry["category"], []).append(entry) self.ctx.save() - self._reload_ext_tree() + self._reload_connectors() - def _ext_edit(self) -> None: - """Configure the selected row. Built-in Jira → its minimal dialog; - a normal connector → the MCP/REST editor.""" - item = self.ext_tree.currentItem() - data = item.data(0, Qt.UserRole) if item else None - if data and data[0] == "jira_builtin": - self._open_jira_dialog() - return - current = self._current_ext_connector() - if current is None: - return - cat, entry = current + def _edit_ext_entry(self, cat: str, entry: dict) -> None: dlg = ExtConnectorEditDialog(self, category=cat, connector=entry) if dlg.exec(): entry.update(dlg.result_connector()) self.ctx.save() - self._reload_ext_tree() + self._reload_connectors() def _open_jira_dialog(self) -> None: JiraConnectDialog(self.ctx, self).exec() - self._reload_ext_tree() + self._reload_connectors() - def _ext_delete(self) -> None: - current = self._current_ext_connector() - if current is None: - return - cat, entry = current + def _delete_ext_entry(self, cat: str, entry: dict) -> None: if QMessageBox.question( self, tr("settings.ext_delete_btn"), tr("settings.ext_delete_confirm", name=entry.get("name", ""))) != QMessageBox.Yes: return self.ctx.config.ext_connectors[cat].remove(entry) self.ctx.save() - self._reload_ext_tree() + self._reload_connectors() def _refresh_ms365_local_status(self) -> None: from .. import paths @@ -314,16 +362,13 @@ class ConnectorsPanel(QWidget): def _apply_connect_external_enabled(self, on: bool) -> None: """Grey out the per-connector setup when the master switch is off — the agent won't connect to any of them anyway.""" - for w in (self.ext_tree, self.add_btn, self.edit_btn, self.del_btn): + for w in (self._cat_scroll, self.add_btn): w.setEnabled(on) def _retranslate(self) -> None: - self.connect_external_chk.setText(tr("connectors.connect_external")) - self.connect_external_chk.setToolTip(tr("connectors.connect_external_tooltip")) + self.connect_external_sw.setText(tr("connectors.connect_external")) + self.connect_external_sw.setToolTip(tr("connectors.connect_external_tooltip")) self.add_btn.setText(tr("settings.ext_add_btn")) - self.edit_btn.setText(tr("settings.ext_edit_btn")) - self.del_btn.setText(tr("settings.ext_delete_btn")) - self.dbl_hint.setText(tr("connectors.dbl_configure")) self._refresh_ms365_local_status() - self._reload_ext_tree() + self._reload_connectors() self._apply_connect_external_enabled(self.ctx.config.connect_external) diff --git a/ui/ext_connector_dialog.py b/ui/ext_connector_dialog.py index 0f45eb2..2244d4a 100644 --- a/ui/ext_connector_dialog.py +++ b/ui/ext_connector_dialog.py @@ -16,9 +16,14 @@ from PySide6.QtWidgets import ( QLineEdit, QMessageBox, QPushButton, QStackedWidget, QVBoxLayout, QWidget, ) -from ..core.ext_connectors import PRESETS +from ..core.ext_connectors import CATEGORIES, PRESETS from ..i18n import tr +# ms365 has no user-created entries here (see ConnectorsPanel) — it auto-connects +# via its own built-in OneDrive/SharePoint toggles, so it's left off this picker. +_PICKABLE_CATEGORIES = tuple(c for c in CATEGORIES if c != "ms365") +_CATEGORY_LABEL = {"cad": "CAD", "cae": "CAE", "other": "Other"} + class ExtConnectorEditDialog(QDialog): def __init__(self, parent=None, category: str = "cad", connector: Optional[dict] = None): @@ -32,10 +37,20 @@ class ExtConnectorEditDialog(QDialog): lay = QVBoxLayout(self) form = QFormLayout() + # Category picker — the single "+ Thêm connector…" button (Monitoring ▸ + # Công cụ ▸ Connector) has no tree selection to infer this from anymore, + # so the dialog itself asks. Fixed once created, same as the preset. + self.category_combo = QComboBox() + for cat in _PICKABLE_CATEGORIES: + self.category_combo.addItem(_CATEGORY_LABEL.get(cat, cat), cat) + idx = self.category_combo.findData(self.category) + self.category_combo.setCurrentIndex(max(0, idx)) + self.category_combo.setEnabled(not editing) + self.category_combo.currentIndexChanged.connect(self._on_category_changed) + form.addRow(tr("ext.category_label"), self.category_combo) + self.preset_combo = QComboBox() - self.preset_combo.addItem(tr("ext.preset_custom"), "") - for p in PRESETS.get(self.category, []): - self.preset_combo.addItem(p["name"], p["id"]) + self._reload_presets() if editing: self.preset_combo.setEnabled(False) # identity fixed once created form.addRow(tr("ext.preset_label"), self.preset_combo) @@ -105,6 +120,18 @@ class ExtConnectorEditDialog(QDialog): buttons.rejected.connect(self.reject) lay.addWidget(buttons) + def _reload_presets(self) -> None: + self.preset_combo.blockSignals(True) + self.preset_combo.clear() + self.preset_combo.addItem(tr("ext.preset_custom"), "") + for p in PRESETS.get(self.category, []): + self.preset_combo.addItem(p["name"], p["id"]) + self.preset_combo.blockSignals(False) + + def _on_category_changed(self) -> None: + self.category = self.category_combo.currentData() or self.category + self._reload_presets() + def _apply_preset(self) -> None: preset_id = self.preset_combo.currentData() if preset_id and not self.name_edit.text().strip(): diff --git a/ui/task_editor_dialog.py b/ui/task_editor_dialog.py index 3628186..ba608fa 100644 --- a/ui/task_editor_dialog.py +++ b/ui/task_editor_dialog.py @@ -70,18 +70,20 @@ class TaskEditorDialog(QDialog): self.edited_task: Optional[dict] = None self.setWindowTitle(tr("schedtask.editor_title_edit" if task else "schedtask.editor_title_new")) self.resize(560, 680) - # Flat inputs: every field (text, list, combo, spin, date) is transparent - # so it shows the page background (the app theme otherwise fills inputs - # with a lighter box) — just a light outline, consistent with the rest of - # the app. The combo drop-down popup keeps a solid dark background so its - # items stay readable. - # Inputs in this dense form sit flat on the dialog rather than on their - # own raised surface — the app-wide sheet styles everything else here, - # including the combo popup, so nothing needs a colour override. - self.setStyleSheet( - "QLineEdit, QPlainTextEdit, QListWidget, QComboBox, QAbstractSpinBox {" - " background: transparent; }" - "QListWidget::item { background: transparent; }") + # Only the files/links/depends-on lists go flat (transparent, no boxed + # panel) — they sit right next to their own +/trash buttons, which is + # enough affordance without a filled background. Title/Description/ + # Prompt/combos etc. keep the app's normal raised-surface + border + # look (theme.py's default for these widget types): a transparent + # single/multi-line box with only a 1px border was tried here and + # turned out too faint against the group's own background to read as + # an editable field at all ("không thể nhận ra ô textbox của prompt"). + # Scoped to #flatList, not bare QListWidget — that would also blank + # out the sectionIndex sidebar's :selected highlight (set by the app + # theme), since a stylesheet set directly on this dialog overrides the + # app-wide one for every descendant it matches, regardless of the + # theme rule's own selector specificity. + self.setStyleSheet("QListWidget#flatList, QListWidget#flatList::item { background: transparent; }") outer = QVBoxLayout(self) scroll = QScrollArea() @@ -93,11 +95,11 @@ class TaskEditorDialog(QDialog): root = QVBoxLayout(content) # ---- basics ---------------------------------------------------- - # Held in its own widget so the whole block can be moved into a step - # tab below; it is a bare form, not a group box. - self._basic_box = QWidget() + # A QGroupBox like the other four step pages (Schedule/Input/ + # Dependency/Execution), so this one isn't the odd one out once it's + # moved into its own step page below (bare background, no title). + self._basic_box = QGroupBox(tr("schedtask.g_basic")) form = QFormLayout(self._basic_box) - form.setContentsMargins(0, 0, 0, 0) self.title_edit = QLineEdit(self.task.get("title", "")) # Description is the source of truth. Its ✨ button GENERATES the Prompt # (Input) FROM the description — the title is just the task's label and @@ -322,6 +324,7 @@ class TaskEditorDialog(QDialog): # multi-select file dialog and APPENDS (never wipes what's already # there), the trash button removes just the selected row(s). self.files_list = QListWidget() + self.files_list.setObjectName("flatList") self.files_list.setMaximumHeight(90) self.files_list.setSelectionMode(QListWidget.ExtendedSelection) for p in inp.get("file_paths", []) or []: @@ -347,6 +350,7 @@ class TaskEditorDialog(QDialog): # Links — same "+"-list pattern; "+" prompts for one URL at a time # (fetched best-effort and inlined as context, same as file attachments). self.links_list = QListWidget() + self.links_list.setObjectName("flatList") self.links_list.setMaximumHeight(90) self.links_list.setSelectionMode(QListWidget.ExtendedSelection) for u in inp.get("links", []) or []: @@ -396,6 +400,7 @@ class TaskEditorDialog(QDialog): # Fan-in: tick every task this one must WAIT for — it won't run until # ALL of them are Done (parallel predecessors feeding one successor). self.depends_list = QListWidget() + self.depends_list.setObjectName("flatList") self.depends_list.setMaximumHeight(96) current_deps = set(dep.get("depends_on") or []) for t in self.all_tasks: diff --git a/ui/tools_admin_tab.py b/ui/tools_admin_tab.py index d939057..226008f 100644 --- a/ui/tools_admin_tab.py +++ b/ui/tools_admin_tab.py @@ -2,8 +2,9 @@ Two sub-tabs: * "Tool" — built-in agent tools (read/write/edit files, run commands, - install packages, fetch URLs); toggling one OFF removes it - from the agent's toolset (persisted in ``config.tools_disabled``). + install packages, fetch URLs) as a left-aligned card grid; + toggling one OFF removes it from the agent's toolset + (persisted in ``config.tools_disabled``). * "Connector" — the full Connectors (MCP / REST API) setup, moved here from Settings: add/edit/delete CAD/CAE/MS365/Other connectors and enable/disable each (``ConnectorsPanel``). @@ -11,9 +12,10 @@ Two sub-tabs: from __future__ import annotations from PySide6.QtCore import Qt +from PySide6.QtGui import QColor, QPainter, QPixmap from PySide6.QtWidgets import ( - QCheckBox, QHBoxLayout, QHeaderView, QLabel, QPushButton, - QTableWidget, QTableWidgetItem, QTabWidget, QVBoxLayout, QWidget, + QFrame, QHBoxLayout, QLabel, QPushButton, QScrollArea, QTabWidget, + QVBoxLayout, QWidget, ) from ..core.tools import TOOL_SPECS @@ -22,18 +24,46 @@ from ..i18n import on_language_changed, tr from ..state import AppContext from .connectors_panel import ConnectorsPanel from .icons import icon +from .widgets import FlowLayout, ToggleSwitch, enable_height_for_width, style_card + +# Identity colour + icon per built-in tool — same "fixed colour regardless of +# theme" convention as monitoring_tab.py's agent avatars / agents_admin_tab.py's +# kind avatars, grouped by what the tool actually touches (file i/o, shell, +# packages, network, Jira). +_TOOL_COLOUR = { + "read_file": "#0078D4", "list_dir": "#0078D4", "write_file": "#0078D4", + "edit_file": "#0078D4", "run_command": "#107C10", "install_package": "#8764B8", + "fetch_url": "#FFB900", "jira_search": "#8764B8", "jira_get_issue": "#8764B8", +} +_TOOL_ICON_NAME = { + "read_file": "document", "list_dir": "folder", "write_file": "new", + "edit_file": "edit", "run_command": "terminal", "install_package": "download", + "fetch_url": "globe", "jira_search": "search", "jira_get_issue": "link", +} -def _center_checkbox(checked: bool, on_toggle) -> QWidget: - box = QWidget() - lay = QHBoxLayout(box) - lay.setContentsMargins(0, 0, 0, 0) - lay.setAlignment(Qt.AlignCenter) - chk = QCheckBox() - chk.setChecked(checked) - chk.toggled.connect(on_toggle) - lay.addWidget(chk) - return box +def _tool_icon_pixmap(name: str, size: int = 28) -> QPixmap: + pm = QPixmap(size, size) + pm.fill(Qt.transparent) + p = QPainter(pm) + p.setRenderHint(QPainter.Antialiasing) + p.setPen(Qt.NoPen) + p.setBrush(QColor(_TOOL_COLOUR.get(name, "#0078D4"))) + r = size * 0.28 + p.drawRoundedRect(0, 0, size, size, r, r) + inner = int(size * 0.58) + glyph = icon(_TOOL_ICON_NAME.get(name, "puzzle"), size=inner, color="#FFFFFF").pixmap(inner, inner) + p.drawPixmap((size - inner) // 2, (size - inner) // 2, glyph) + p.end() + return pm + + +def _clear_flow(flow: FlowLayout) -> None: + while flow.count(): + item = flow.takeAt(0) + w = item.widget() + if w is not None: + w.deleteLater() class ToolsAdminTab(QWidget): @@ -54,21 +84,20 @@ class ToolsAdminTab(QWidget): self._hint.setWordWrap(True) tl.addWidget(self._hint) - self.table = QTableWidget(0, 3) - self.table.setEditTriggers(QTableWidget.NoEditTriggers) - self.table.verticalHeader().setVisible(False) - # Description is the long column — IT stretches to fill remaining - # width (was Name, leaving Description squeezed into whatever was - # left over); Name/Enabled size to their own content. - self.table.horizontalHeader().setSectionResizeMode(0, QHeaderView.ResizeToContents) - self.table.horizontalHeader().setSectionResizeMode(1, QHeaderView.Stretch) - self.table.horizontalHeader().setSectionResizeMode(2, QHeaderView.ResizeToContents) - self.table.setWordWrap(True) - tl.addWidget(self.table, 1) + # A left-aligned, wrapping card grid — one card per built-in tool + # (colour-coded icon + name + toggle switch + description), replacing + # the old flat Name/Description/Enabled table. + scroll = QScrollArea() + scroll.setWidgetResizable(True) + scroll.setFrameShape(QScrollArea.NoFrame) + cards_host = QWidget() + self._tool_flow = FlowLayout(cards_host, margin=0, h_spacing=10, v_spacing=10) + scroll.setWidget(cards_host) + tl.addWidget(scroll, 1) - # "Test Internet" self-test lives INSIDE the fetch_url tool row now (see - # refresh) instead of a separate boxed section — persistent widgets so - # they survive table rebuilds. + # "Test Internet" self-test lives INSIDE the fetch_url tool's card now + # (see refresh) instead of a separate boxed section — persistent + # widgets so they survive card rebuilds. self.test_internet_btn = QPushButton(tr("settings.test_internet")) self.test_internet_btn.setIcon(icon("globe")) self.test_internet_btn.setToolTip(tr("settings.test_internet_tooltip")) @@ -94,51 +123,71 @@ class ToolsAdminTab(QWidget): self.connectors_panel = ConnectorsPanel(ctx) self.subtabs.addTab(self.connectors_panel, "") + # on_language_changed() already invokes _retranslate() once immediately + # (see i18n.py) — a second explicit call here double-populates the + # card grid back-to-back with no event-loop turn in between, so the + # first pass's cards are only queued for deleteLater() (not yet gone) + # when the second pass adds new ones on top (see connectors_panel.py's + # ConnectorsPanel, which hit the exact same bug this same way). on_language_changed(self._retranslate) - self._retranslate() - # ---- built-in tools table ------------------------------------------------- + # ---- built-in tools card grid --------------------------------------------- def refresh(self) -> None: disabled = set(self.ctx.config.tools_disabled) - specs = list(TOOL_SPECS) - self.table.setRowCount(len(specs)) - for r, spec in enumerate(specs): - self.table.setItem(r, 0, QTableWidgetItem(spec.name)) - # Full description (was truncated to 80 chars, hiding the rest) — - # word-wraps inside the stretched column; resizeRowToContents - # below grows the row to fit however many lines that takes. - if spec.name == "fetch_url": - # This tool's row carries the live "Test Internet" self-test - # right below its description — no separate boxed section. - self.table.setItem(r, 1, None) - self.table.setCellWidget(r, 1, self._fetch_url_desc_cell(spec)) - else: - desc_item = QTableWidgetItem(spec.description) - desc_item.setToolTip(spec.description) - self.table.setItem(r, 1, desc_item) - self.table.setCellWidget( - r, 2, _center_checkbox(spec.name not in disabled, - lambda on, n=spec.name: self._toggle_builtin(n, on))) - # Once ALL rows/columns are populated (so the stretched Description - # column has its real width), grow each row to fit its wrapped text. - self.table.resizeRowsToContents() + _clear_flow(self._tool_flow) + for spec in TOOL_SPECS: + self._tool_flow.addWidget(self._tool_card(spec, spec.name not in disabled)) + + def _tool_card(self, spec, enabled: bool) -> QWidget: + card = QFrame() + card.setFrameShape(QFrame.NoFrame) + style_card(card) + card.setFixedWidth(220) + # The description below wraps to a variable number of lines at this + # fixed width, so the card's own height depends on its width — without + # this, the outer FlowLayout's QWidgetItem queries card.sizePolicy() + # (not the description label's), gets a too-short sizeHint, and + # squeezes the card into less height than its QVBoxLayout needs, + # which is what overlapped the header onto the description text. + enable_height_for_width(card) + lay = QVBoxLayout(card) + lay.setContentsMargins(10, 8, 10, 8) + lay.setSpacing(4) + + hdr = QHBoxLayout() + icon_lbl = QLabel() + icon_lbl.setPixmap(_tool_icon_pixmap(spec.name)) + icon_lbl.setStyleSheet("border: none;") + hdr.addWidget(icon_lbl) + name_lbl = QLabel(spec.name) + name_lbl.setStyleSheet("font-weight:700; border: none;") + hdr.addWidget(name_lbl) + hdr.addStretch(1) + sw = ToggleSwitch() + sw.setChecked(enabled) + sw.toggled.connect(lambda on, n=spec.name: self._toggle_builtin(n, on)) + hdr.addWidget(sw) + lay.addLayout(hdr) - def _fetch_url_desc_cell(self, spec) -> QWidget: - cell = QWidget() - cl = QVBoxLayout(cell) - cl.setContentsMargins(6, 4, 6, 4) - cl.setSpacing(4) desc = QLabel(spec.description) desc.setWordWrap(True) desc.setToolTip(spec.description) - cl.addWidget(desc) - net = QWidget() - nl = QHBoxLayout(net) - nl.setContentsMargins(0, 0, 0, 0) - nl.addWidget(self.test_internet_btn) - nl.addWidget(self.test_internet_status, 1) - cl.addWidget(net) - return cell + desc.setObjectName("hint") + desc.setStyleSheet("border: none;") + lay.addWidget(desc) + + if spec.name == "fetch_url": + # The live "Test Internet" self-test lives inside fetch_url's own + # card — it tests THIS capability, not the tab as a whole. + net = QWidget() + net.setStyleSheet("border: none;") + nl = QHBoxLayout(net) + nl.setContentsMargins(0, 2, 0, 0) + nl.addWidget(self.test_internet_btn) + nl.addWidget(self.test_internet_status, 1) + lay.addWidget(net) + + return card def _toggle_builtin(self, name: str, enabled: bool) -> None: self.ctx.config.set_tool_enabled(name, enabled) @@ -192,9 +241,5 @@ class ToolsAdminTab(QWidget): self.test_internet_btn.setText(tr("settings.test_internet")) self.test_internet_btn.setToolTip(tr("settings.test_internet_tooltip")) self.refresh_btn.setText(tr("tools_admin.refresh")) - self.table.setHorizontalHeaderLabels([ - tr("tools_admin.col_name"), tr("tools_admin.col_desc"), - tr("tools_admin.col_enabled"), - ]) self.jira_note.setText(tr("tools_admin.jira_note")) self.refresh() diff --git a/ui/widgets.py b/ui/widgets.py index 895f254..4835354 100644 --- a/ui/widgets.py +++ b/ui/widgets.py @@ -5,12 +5,14 @@ from __future__ import annotations from pathlib import Path -from PySide6.QtCore import QEvent, QObject, QPointF, QRectF, Qt, Signal +from PySide6.QtCore import ( + QEvent, QObject, QPoint, QPointF, QRect, QRectF, QSize, Qt, Signal, +) from PySide6.QtGui import QColor, QPainter, QPen from PySide6.QtWidgets import ( QAbstractSpinBox, QCheckBox, QComboBox, QDoubleSpinBox, QFrame, - QHBoxLayout, QLabel, QListWidget, QListWidgetItem, QPushButton, QSizePolicy, - QVBoxLayout, QWidget, + QHBoxLayout, QLabel, QLayout, QListWidget, QListWidgetItem, QPushButton, + QSizePolicy, QVBoxLayout, QWidget, ) from ..core.flows import STEP_DONE, STEP_ERROR, STEP_PENDING, STEP_RUNNING @@ -37,7 +39,93 @@ def badge_pill_widget(text: str, object_name: str) -> QWidget: return container -def _style_card(frame: QFrame) -> None: +def enable_height_for_width(widget: QWidget) -> None: + """Flag ``widget`` as height-for-width so a PARENT layout reserves the + right amount of vertical space for it — needed at every widget boundary + between a :class:`FlowLayout` and the outermost layout, since each + ``addWidget()`` hop asks the WIDGET's own sizePolicy, not its layout's + (see FlowLayout's docstring).""" + policy = widget.sizePolicy() + policy.setHeightForWidth(True) + widget.setSizePolicy(policy) + + +class FlowLayout(QLayout): + """A left-aligned layout that wraps its children onto new lines as the + container narrows, each item kept at its own natural size — the + ``.card`` grids in ui-audit_v2.html ("không kéo giãn lấp đầy hàng": cards + stay sized to their own content, never stretched to fill a row). Qt has + no built-in equivalent; this is the standard recipe (Qt's own C++ + FlowLayout example, ported).""" + + def __init__(self, parent=None, margin: int = 0, h_spacing: int = 8, v_spacing: int = 8): + super().__init__(parent) + self._h_spacing = h_spacing + self._v_spacing = v_spacing + self._items: list = [] + self.setContentsMargins(margin, margin, margin, margin) + if parent is not None: + enable_height_for_width(parent) + + def addItem(self, item) -> None: # noqa: N802 - Qt override + self._items.append(item) + + def count(self) -> int: # noqa: N802 - Qt override + return len(self._items) + + def itemAt(self, index: int): # noqa: N802 - Qt override + return self._items[index] if 0 <= index < len(self._items) else None + + def takeAt(self, index: int): # noqa: N802 - Qt override + return self._items.pop(index) if 0 <= index < len(self._items) else None + + def expandingDirections(self): # noqa: N802 - Qt override + return Qt.Orientations(Qt.Orientation(0)) + + def hasHeightForWidth(self) -> bool: # noqa: N802 - Qt override + return True + + def heightForWidth(self, width: int) -> int: # noqa: N802 - Qt override + return self._do_layout(QRect(0, 0, width, 0), test_only=True) + + def setGeometry(self, rect) -> None: # noqa: N802 - Qt override + super().setGeometry(rect) + self._do_layout(rect, test_only=False) + + def sizeHint(self): # noqa: N802 - Qt override + return self.minimumSize() + + def minimumSize(self): # noqa: N802 - Qt override + size = QSize() + for item in self._items: + size = size.expandedTo(item.minimumSize()) + m = self.contentsMargins() + size += QSize(m.left() + m.right(), m.top() + m.bottom()) + return size + + def _do_layout(self, rect, test_only: bool) -> int: + m = self.contentsMargins() + effective = QRect(rect.x() + m.left(), rect.y() + m.top(), + rect.width() - m.left() - m.right(), + rect.height() - m.top() - m.bottom()) + x, y = effective.x(), effective.y() + line_height = 0 + for item in self._items: + hint = item.sizeHint() + next_x = x + hint.width() + self._h_spacing + if next_x - self._h_spacing > effective.right() and line_height > 0: + x = effective.x() + y = y + line_height + self._v_spacing + next_x = x + hint.width() + self._h_spacing + line_height = 0 + if not test_only: + item.setGeometry(QRect(QPoint(x, y), hint)) + x = next_x + line_height = max(line_height, hint.height()) + return y + line_height - rect.y() + m.bottom() + + +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.""" @@ -54,7 +142,7 @@ class StatCard(QFrame): def __init__(self): super().__init__() self.setFrameShape(QFrame.NoFrame) - _style_card(self) + style_card(self) lay = QVBoxLayout(self) self.title_lbl = QLabel("") self.title_lbl.setObjectName("hint") @@ -105,7 +193,7 @@ class BudgetCard(QFrame): def __init__(self): super().__init__() self.setFrameShape(QFrame.NoFrame) - _style_card(self) + style_card(self) lay = QVBoxLayout(self) self.title_lbl = QLabel("") self.title_lbl.setObjectName("hint")