Update Screen: Cong cu, settings, them/Chinh sua task
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
+181
-136
@@ -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)
|
||||
|
||||
@@ -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():
|
||||
|
||||
+21
-16
@@ -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:
|
||||
|
||||
+114
-69
@@ -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()
|
||||
|
||||
+94
-6
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user