1
0
mirror of https://github.com/bec-project/bec_widgets.git synced 2026-04-09 10:10:55 +02:00

Compare commits

...

4 Commits

Author SHA1 Message Date
fde7b4db6c scratch - device manager 2025-08-18 09:59:54 +02:00
semantic-release
a2f8880459 2.35.0
Automatically generated by python-semantic-release
2025-08-14 07:16:53 +00:00
926d722955 feat(property_manager): property manager widget 2025-08-14 09:16:04 +02:00
44ba7201b4 build: PySide6 upgraded to 6.9.0 2025-08-12 19:56:29 +02:00
5 changed files with 2293 additions and 2 deletions

View File

@@ -1,6 +1,19 @@
# CHANGELOG
## v2.35.0 (2025-08-14)
### Build System
- Pyside6 upgraded to 6.9.0
([`44ba720`](https://github.com/bec-project/bec_widgets/commit/44ba7201b4914d63281bbed5e62d07e5c240595a))
### Features
- **property_manager**: Property manager widget
([`926d722`](https://github.com/bec-project/bec_widgets/commit/926d7229559d189d382fe034b3afbc544e709efa))
## v2.34.0 (2025-08-07)
### Bug Fixes

View File

@@ -0,0 +1,694 @@
from __future__ import annotations
from qtpy.QtCore import QLocale, QMetaEnum, Qt, QTimer
from qtpy.QtGui import QColor, QCursor, QFont, QIcon, QPalette
from qtpy.QtWidgets import (
QCheckBox,
QColorDialog,
QComboBox,
QDoubleSpinBox,
QFileDialog,
QFontDialog,
QHBoxLayout,
QHeaderView,
QLabel,
QLineEdit,
QMenu,
QPushButton,
QSizePolicy,
QSpinBox,
QToolButton,
QTreeWidget,
QTreeWidgetItem,
QVBoxLayout,
QWidget,
)
class PropertyEditor(QWidget):
def __init__(self, target: QWidget, parent: QWidget | None = None, show_only_bec: bool = True):
super().__init__(parent)
self._target = target
self._bec_only = show_only_bec
layout = QVBoxLayout(self)
layout.setContentsMargins(0, 0, 0, 0)
# Name row
name_row = QHBoxLayout()
name_row.addWidget(QLabel("Name:"))
self.name_edit = QLineEdit(target.objectName())
self.name_edit.setEnabled(False) # TODO implement with RPC broadcast
name_row.addWidget(self.name_edit)
layout.addLayout(name_row)
# BEC only checkbox
filter_row = QHBoxLayout()
self.chk_show_qt = QCheckBox("Show Qt properties")
self.chk_show_qt.setChecked(False)
filter_row.addWidget(self.chk_show_qt)
filter_row.addStretch(1)
layout.addLayout(filter_row)
self.chk_show_qt.toggled.connect(lambda checked: self.set_show_only_bec(not checked))
# Main tree widget
self.tree = QTreeWidget(self)
self.tree.setColumnCount(2)
self.tree.setHeaderLabels(["Property", "Value"])
self.tree.setAlternatingRowColors(True)
self.tree.setRootIsDecorated(False)
layout.addWidget(self.tree)
self._build()
def _class_chain(self):
chain = []
mo = self._target.metaObject()
while mo is not None:
chain.append(mo)
mo = mo.superClass()
return chain
def set_show_only_bec(self, flag: bool):
self._bec_only = flag
self._build()
def _set_equal_columns(self):
header = self.tree.header()
header.setSectionResizeMode(0, QHeaderView.Interactive)
header.setSectionResizeMode(1, QHeaderView.Interactive)
w = self.tree.viewport().width() or self.tree.width()
if w > 0:
half = max(1, w // 2)
self.tree.setColumnWidth(0, half)
self.tree.setColumnWidth(1, w - half)
def _build(self):
self.tree.clear()
for mo in self._class_chain():
class_name = mo.className()
if self._bec_only and not self._is_bec_metaobject(mo):
continue
group_item = QTreeWidgetItem(self.tree, [class_name])
group_item.setFirstColumnSpanned(True)
start = mo.propertyOffset()
end = mo.propertyCount()
for i in range(start, end):
prop = mo.property(i)
if (
not prop.isReadable()
or not prop.isWritable()
or not prop.isStored()
or not prop.isDesignable()
):
continue
name = prop.name()
if name == "objectName":
continue
value = self._target.property(name)
self._add_property_row(group_item, name, value, prop)
if group_item.childCount() == 0:
idx = self.tree.indexOfTopLevelItem(group_item)
self.tree.takeTopLevelItem(idx)
self.tree.expandAll()
QTimer.singleShot(0, self._set_equal_columns)
def _enum_int(self, obj) -> int:
return int(getattr(obj, "value", obj))
def _make_sizepolicy_editor(self, name: str, sp):
if not isinstance(sp, QSizePolicy):
return None
wrap = QWidget(self)
row = QHBoxLayout(wrap)
row.setContentsMargins(0, 0, 0, 0)
row.setSpacing(4)
h_combo = QComboBox(wrap)
v_combo = QComboBox(wrap)
hs = QSpinBox(wrap)
vs = QSpinBox(wrap)
for b in (hs, vs):
b.setRange(0, 16777215)
policies = [
(QSizePolicy.Fixed, "Fixed"),
(QSizePolicy.Minimum, "Minimum"),
(QSizePolicy.Maximum, "Maximum"),
(QSizePolicy.Preferred, "Preferred"),
(QSizePolicy.Expanding, "Expanding"),
(QSizePolicy.MinimumExpanding, "MinExpanding"),
(QSizePolicy.Ignored, "Ignored"),
]
for pol, text in policies:
h_combo.addItem(text, self._enum_int(pol))
v_combo.addItem(text, self._enum_int(pol))
def _set_current(combo, val):
idx = combo.findData(self._enum_int(val))
if idx >= 0:
combo.setCurrentIndex(idx)
_set_current(h_combo, sp.horizontalPolicy())
_set_current(v_combo, sp.verticalPolicy())
hs.setValue(sp.horizontalStretch())
vs.setValue(sp.verticalStretch())
def apply_changes():
hp = QSizePolicy.Policy(h_combo.currentData())
vp = QSizePolicy.Policy(v_combo.currentData())
nsp = QSizePolicy(hp, vp)
nsp.setHorizontalStretch(hs.value())
nsp.setVerticalStretch(vs.value())
self._target.setProperty(name, nsp)
h_combo.currentIndexChanged.connect(lambda _=None: apply_changes())
v_combo.currentIndexChanged.connect(lambda _=None: apply_changes())
hs.valueChanged.connect(lambda _=None: apply_changes())
vs.valueChanged.connect(lambda _=None: apply_changes())
row.addWidget(h_combo)
row.addWidget(v_combo)
row.addWidget(hs)
row.addWidget(vs)
return wrap
def _make_locale_editor(self, name: str, loc):
if not isinstance(loc, QLocale):
return None
wrap = QWidget(self)
row = QHBoxLayout(wrap)
row.setContentsMargins(0, 0, 0, 0)
row.setSpacing(4)
lang_combo = QComboBox(wrap)
country_combo = QComboBox(wrap)
for lang in QLocale.Language:
try:
lang_int = self._enum_int(lang)
except Exception:
continue
if lang_int < 0:
continue
name_txt = QLocale.languageToString(QLocale.Language(lang_int))
lang_combo.addItem(name_txt, lang_int)
def populate_countries():
country_combo.blockSignals(True)
country_combo.clear()
for terr in QLocale.Country:
try:
terr_int = self._enum_int(terr)
except Exception:
continue
if terr_int < 0:
continue
text = QLocale.countryToString(QLocale.Country(terr_int))
country_combo.addItem(text, terr_int)
cur_country = self._enum_int(loc.country())
idx = country_combo.findData(cur_country)
if idx >= 0:
country_combo.setCurrentIndex(idx)
country_combo.blockSignals(False)
cur_lang = self._enum_int(loc.language())
idx = lang_combo.findData(cur_lang)
if idx >= 0:
lang_combo.setCurrentIndex(idx)
populate_countries()
def apply_locale():
lang = QLocale.Language(int(lang_combo.currentData()))
country = QLocale.Country(int(country_combo.currentData()))
self._target.setProperty(name, QLocale(lang, country))
lang_combo.currentIndexChanged.connect(lambda _=None: populate_countries())
lang_combo.currentIndexChanged.connect(lambda _=None: apply_locale())
country_combo.currentIndexChanged.connect(lambda _=None: apply_locale())
row.addWidget(lang_combo)
row.addWidget(country_combo)
return wrap
def _make_icon_editor(self, name: str, icon):
btn = QPushButton(self)
btn.setText("Choose…")
if isinstance(icon, QIcon) and not icon.isNull():
btn.setIcon(icon)
def pick():
path, _ = QFileDialog.getOpenFileName(
self, "Select Icon", "", "Images (*.png *.jpg *.jpeg *.bmp *.svg)"
)
if path:
ic = QIcon(path)
self._target.setProperty(name, ic)
btn.setIcon(ic)
btn.clicked.connect(pick)
return btn
def _spin_pair(self, ints: bool = True):
box1 = QSpinBox(self) if ints else QDoubleSpinBox(self)
box2 = QSpinBox(self) if ints else QDoubleSpinBox(self)
if ints:
box1.setRange(-10_000_000, 10_000_000)
box2.setRange(-10_000_000, 10_000_000)
else:
for b in (box1, box2):
b.setDecimals(6)
b.setRange(-1e12, 1e12)
b.setSingleStep(0.1)
row = QHBoxLayout()
row.setContentsMargins(0, 0, 0, 0)
row.setSpacing(4)
wrap = QWidget(self)
wrap.setLayout(row)
row.addWidget(box1)
row.addWidget(box2)
return wrap, box1, box2
def _spin_quad(self, ints: bool = True):
s = QSpinBox if ints else QDoubleSpinBox
boxes = [s(self) for _ in range(4)]
if ints:
for b in boxes:
b.setRange(-10_000_000, 10_000_000)
else:
for b in boxes:
b.setDecimals(6)
b.setRange(-1e12, 1e12)
b.setSingleStep(0.1)
row = QHBoxLayout()
row.setContentsMargins(0, 0, 0, 0)
row.setSpacing(4)
wrap = QWidget(self)
wrap.setLayout(row)
for b in boxes:
row.addWidget(b)
return wrap, boxes
def _make_font_editor(self, name: str, value):
btn = QPushButton(self)
if isinstance(value, QFont):
btn.setText(f"{value.family()}, {value.pointSize()}pt")
else:
btn.setText("Select font…")
def pick():
ok, font = QFontDialog.getFont(
value if isinstance(value, QFont) else QFont(), self, "Select Font"
)
if ok:
self._target.setProperty(name, font)
btn.setText(f"{font.family()}, {font.pointSize()}pt")
btn.clicked.connect(pick)
return btn
def _make_color_editor(self, initial: QColor, apply_cb):
btn = QPushButton(self)
if isinstance(initial, QColor):
btn.setText(initial.name())
btn.setStyleSheet(f"background:{initial.name()};")
else:
btn.setText("Select color…")
def pick():
col = QColorDialog.getColor(
initial if isinstance(initial, QColor) else QColor(), self, "Select Color"
)
if col.isValid():
apply_cb(col)
btn.setText(col.name())
btn.setStyleSheet(f"background:{col.name()};")
btn.clicked.connect(pick)
return btn
def _apply_palette_color(
self,
name: str,
pal: QPalette,
group: QPalette.ColorGroup,
role: QPalette.ColorRole,
col: QColor,
):
pal.setColor(group, role, col)
self._target.setProperty(name, pal)
def _make_palette_editor(self, name: str, pal: QPalette):
if not isinstance(pal, QPalette):
return None
wrap = QWidget(self)
row = QHBoxLayout(wrap)
row.setContentsMargins(0, 0, 0, 0)
group_combo = QComboBox(wrap)
role_combo = QComboBox(wrap)
pick_btn = self._make_color_editor(
pal.color(QPalette.Active, QPalette.WindowText),
lambda col: self._apply_palette_color(
name, pal, QPalette.Active, QPalette.WindowText, col
),
)
groups = [
(QPalette.Active, "Active"),
(QPalette.Inactive, "Inactive"),
(QPalette.Disabled, "Disabled"),
]
for g, label in groups:
group_combo.addItem(label, int(getattr(g, "value", g)))
roles = [
(QPalette.WindowText, "WindowText"),
(QPalette.Window, "Window"),
(QPalette.Base, "Base"),
(QPalette.AlternateBase, "AlternateBase"),
(QPalette.ToolTipBase, "ToolTipBase"),
(QPalette.ToolTipText, "ToolTipText"),
(QPalette.Text, "Text"),
(QPalette.Button, "Button"),
(QPalette.ButtonText, "ButtonText"),
(QPalette.BrightText, "BrightText"),
(QPalette.Highlight, "Highlight"),
(QPalette.HighlightedText, "HighlightedText"),
]
for r, label in roles:
role_combo.addItem(label, int(getattr(r, "value", r)))
def rewire_button():
g = QPalette.ColorGroup(int(group_combo.currentData()))
r = QPalette.ColorRole(int(role_combo.currentData()))
col = pal.color(g, r)
while row.count() > 2:
w = row.takeAt(2).widget()
if w:
w.deleteLater()
btn = self._make_color_editor(
col, lambda c: self._apply_palette_color(name, pal, g, r, c)
)
row.addWidget(btn)
group_combo.currentIndexChanged.connect(lambda _: rewire_button())
role_combo.currentIndexChanged.connect(lambda _: rewire_button())
row.addWidget(group_combo)
row.addWidget(role_combo)
row.addWidget(pick_btn)
return wrap
def _make_cursor_editor(self, name: str, value):
combo = QComboBox(self)
shapes = [
(Qt.ArrowCursor, "Arrow"),
(Qt.IBeamCursor, "IBeam"),
(Qt.WaitCursor, "Wait"),
(Qt.CrossCursor, "Cross"),
(Qt.UpArrowCursor, "UpArrow"),
(Qt.SizeAllCursor, "SizeAll"),
(Qt.PointingHandCursor, "PointingHand"),
(Qt.ForbiddenCursor, "Forbidden"),
(Qt.WhatsThisCursor, "WhatsThis"),
(Qt.BusyCursor, "Busy"),
]
current_shape = None
if isinstance(value, QCursor):
try:
enum_val = value.shape()
current_shape = int(getattr(enum_val, "value", enum_val))
except Exception:
current_shape = None
for shape, text in shapes:
combo.addItem(text, int(getattr(shape, "value", shape)))
if current_shape is not None:
idx = combo.findData(current_shape)
if idx >= 0:
combo.setCurrentIndex(idx)
def apply_index(i):
shape_val = int(combo.itemData(i))
self._target.setProperty(name, QCursor(Qt.CursorShape(shape_val)))
combo.currentIndexChanged.connect(apply_index)
return combo
def _add_property_row(self, parent: QTreeWidgetItem, name: str, value, prop):
item = QTreeWidgetItem(parent, [name, ""])
editor = self._make_editor(name, value, prop)
if editor is not None:
self.tree.setItemWidget(item, 1, editor)
else:
item.setText(1, repr(value))
def _is_bec_metaobject(self, mo) -> bool:
cname = mo.className()
for cls in type(self._target).mro():
if getattr(cls, "__name__", None) == cname:
mod = getattr(cls, "__module__", "")
return mod.startswith("bec_widgets")
return False
def _enum_text(self, meta_enum: QMetaEnum, value_int: int) -> str:
if not meta_enum.isFlag():
key = meta_enum.valueToKey(value_int)
return key.decode() if isinstance(key, (bytes, bytearray)) else (key or str(value_int))
parts = []
for i in range(meta_enum.keyCount()):
k = meta_enum.key(i)
v = meta_enum.value(i)
if value_int & v:
k = k.decode() if isinstance(k, (bytes, bytearray)) else k
parts.append(k)
return " | ".join(parts) if parts else "0"
def _enum_value_to_int(self, meta_enum: QMetaEnum, value) -> int:
try:
return int(value)
except Exception:
pass
v = getattr(value, "value", None)
if isinstance(v, (int,)):
return int(v)
n = getattr(value, "name", None)
if isinstance(n, str):
res = meta_enum.keyToValue(n)
if res != -1:
return int(res)
s = str(value)
parts = [p.strip() for p in s.replace(",", "|").split("|")]
keys = []
for p in parts:
if "." in p:
p = p.split(".")[-1]
keys.append(p)
keystr = "|".join(keys)
try:
res = meta_enum.keysToValue(keystr)
if res != -1:
return int(res)
except Exception:
pass
return 0
def _make_enum_editor(self, name: str, value, prop):
meta_enum = prop.enumerator()
current = self._enum_value_to_int(meta_enum, value)
if not meta_enum.isFlag():
combo = QComboBox(self)
for i in range(meta_enum.keyCount()):
key = meta_enum.key(i)
key = key.decode() if isinstance(key, (bytes, bytearray)) else key
combo.addItem(key, meta_enum.value(i))
idx = combo.findData(current)
if idx < 0:
txt = self._enum_text(meta_enum, current)
idx = combo.findText(txt)
combo.setCurrentIndex(max(idx, 0))
def apply_index(i):
v = combo.itemData(i)
self._target.setProperty(name, int(v))
combo.currentIndexChanged.connect(apply_index)
return combo
btn = QToolButton(self)
btn.setText(self._enum_text(meta_enum, current))
btn.setPopupMode(QToolButton.InstantPopup)
menu = QMenu(btn)
actions = []
for i in range(meta_enum.keyCount()):
key = meta_enum.key(i)
key = key.decode() if isinstance(key, (bytes, bytearray)) else key
act = menu.addAction(key)
act.setCheckable(True)
act.setChecked(bool(current & meta_enum.value(i)))
actions.append(act)
btn.setMenu(menu)
def apply_flags():
flags = 0
for i, act in enumerate(actions):
if act.isChecked():
flags |= meta_enum.value(i)
self._target.setProperty(name, int(flags))
btn.setText(self._enum_text(meta_enum, flags))
menu.triggered.connect(lambda _a: apply_flags())
return btn
def _make_editor(self, name: str, value, prop):
from qtpy.QtCore import QPoint, QPointF, QRect, QRectF, QSize, QSizeF
if prop.isEnumType():
return self._make_enum_editor(name, value, prop)
if isinstance(value, QColor):
return self._make_color_editor(value, lambda col: self._target.setProperty(name, col))
if isinstance(value, QFont):
return self._make_font_editor(name, value)
if isinstance(value, QPalette):
return self._make_palette_editor(name, value)
if isinstance(value, QCursor):
return self._make_cursor_editor(name, value)
if isinstance(value, QSizePolicy):
ed = self._make_sizepolicy_editor(name, value)
if ed is not None:
return ed
if isinstance(value, QLocale):
ed = self._make_locale_editor(name, value)
if ed is not None:
return ed
if isinstance(value, QIcon):
ed = self._make_icon_editor(name, value)
if ed is not None:
return ed
if isinstance(value, QSize):
wrap, w, h = self._spin_pair(ints=True)
w.setValue(value.width())
h.setValue(value.height())
w.valueChanged.connect(
lambda _: self._target.setProperty(name, QSize(w.value(), h.value()))
)
h.valueChanged.connect(
lambda _: self._target.setProperty(name, QSize(w.value(), h.value()))
)
return wrap
if isinstance(value, QSizeF):
wrap, w, h = self._spin_pair(ints=False)
w.setValue(value.width())
h.setValue(value.height())
w.valueChanged.connect(
lambda _: self._target.setProperty(name, QSizeF(w.value(), h.value()))
)
h.valueChanged.connect(
lambda _: self._target.setProperty(name, QSizeF(w.value(), h.value()))
)
return wrap
if isinstance(value, QPoint):
wrap, x, y = self._spin_pair(ints=True)
x.setValue(value.x())
y.setValue(value.y())
x.valueChanged.connect(
lambda _: self._target.setProperty(name, QPoint(x.value(), y.value()))
)
y.valueChanged.connect(
lambda _: self._target.setProperty(name, QPoint(x.value(), y.value()))
)
return wrap
if isinstance(value, QPointF):
wrap, x, y = self._spin_pair(ints=False)
x.setValue(value.x())
y.setValue(value.y())
x.valueChanged.connect(
lambda _: self._target.setProperty(name, QPointF(x.value(), y.value()))
)
y.valueChanged.connect(
lambda _: self._target.setProperty(name, QPointF(x.value(), y.value()))
)
return wrap
if isinstance(value, QRect):
wrap, boxes = self._spin_quad(ints=True)
for b, v in zip(boxes, (value.x(), value.y(), value.width(), value.height())):
b.setValue(v)
def apply_rect():
self._target.setProperty(
name,
QRect(boxes[0].value(), boxes[1].value(), boxes[2].value(), boxes[3].value()),
)
for b in boxes:
b.valueChanged.connect(lambda _=None: apply_rect())
return wrap
if isinstance(value, QRectF):
wrap, boxes = self._spin_quad(ints=False)
for b, v in zip(boxes, (value.x(), value.y(), value.width(), value.height())):
b.setValue(v)
def apply_rectf():
self._target.setProperty(
name,
QRectF(boxes[0].value(), boxes[1].value(), boxes[2].value(), boxes[3].value()),
)
for b in boxes:
b.valueChanged.connect(lambda _=None: apply_rectf())
return wrap
if isinstance(value, bool):
w = QCheckBox(self)
w.setChecked(bool(value))
w.toggled.connect(lambda v: self._target.setProperty(name, v))
return w
if isinstance(value, int) and not isinstance(value, bool):
w = QSpinBox(self)
w.setRange(-10_000_000, 10_000_000)
w.setValue(int(value))
w.valueChanged.connect(lambda v: self._target.setProperty(name, v))
return w
if isinstance(value, float):
w = QDoubleSpinBox(self)
w.setDecimals(6)
w.setRange(-1e12, 1e12)
w.setSingleStep(0.1)
w.setValue(float(value))
w.valueChanged.connect(lambda v: self._target.setProperty(name, v))
return w
if isinstance(value, str):
w = QLineEdit(self)
w.setText(value)
w.editingFinished.connect(lambda: self._target.setProperty(name, w.text()))
return w
return None
class DemoApp(QWidget): # pragma: no cover:
def __init__(self):
super().__init__()
layout = QHBoxLayout(self)
# Create a BECWidget instance example
waveform = self.create_waveform()
# property editor for the BECWidget
property_editor = PropertyEditor(waveform, show_only_bec=True)
layout.addWidget(waveform)
layout.addWidget(property_editor)
def create_waveform(self):
"""Create a new waveform widget."""
from bec_widgets.widgets.plots.waveform.waveform import Waveform
waveform = Waveform(parent=self)
waveform.title = "New Waveform"
waveform.x_label = "X Axis"
waveform.y_label = "Y Axis"
return waveform
if __name__ == "__main__": # pragma: no cover:
import sys
from qtpy.QtWidgets import QApplication
app = QApplication(sys.argv)
demo = DemoApp()
demo.setWindowTitle("Property Editor Demo")
demo.resize(1200, 800)
demo.show()
sys.exit(app.exec())

View File

@@ -0,0 +1,998 @@
from __future__ import annotations
from qtpy.QtCore import QSize, QSortFilterProxyModel, Qt
from qtpy.QtWidgets import (
QCheckBox,
QComboBox,
QDialog,
QFormLayout,
QFrame,
QHBoxLayout,
QHeaderView,
QLabel,
QLineEdit,
QListWidget,
QListWidgetItem,
QMessageBox,
QPushButton,
QScrollArea,
QSizePolicy,
QSplitter,
QTableWidget,
QTableWidgetItem,
QTreeWidget,
QTreeWidgetItem,
QVBoxLayout,
QWidget,
)
from thefuzz import fuzz
from bec_widgets.utils.bec_table import BECTable
from bec_widgets.utils.bec_widget import BECWidget
from bec_widgets.utils.error_popups import ErrorPopupUtility, SafeSlot
from bec_widgets.widgets.utility.toggle.toggle import ToggleSwitch
class CheckBoxCenterWidget(QWidget):
"""Widget to center a checkbox in a table cell."""
def __init__(self, checked=False, parent=None):
super().__init__(parent)
layout = QHBoxLayout(self)
layout.setAlignment(Qt.AlignCenter)
layout.setContentsMargins(4, 0, 4, 0) # Reduced margins for more compact layout
self.checkbox = QCheckBox()
self.checkbox.setChecked(checked)
self.checkbox.setEnabled(False) # Read-only
# Store the value for sorting
self.value = checked
layout.addWidget(self.checkbox)
class TextLabelWidget(QWidget):
"""Widget to display text with word wrapping in a table cell."""
def __init__(self, text="", parent=None):
super().__init__(parent)
# Use a layout with minimal margins to maximize text display area
layout = QVBoxLayout(self)
layout.setContentsMargins(2, 2, 2, 2)
layout.setSpacing(0)
# Create label with word wrap enabled
self.label = QLabel(text)
self.label.setWordWrap(True)
self.label.setTextInteractionFlags(Qt.TextSelectableByMouse)
self.label.setAlignment(Qt.AlignLeft | Qt.AlignTop) # Align to top-left
# Make sure label expands to fill available space
self.label.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding)
# Store the text value for sorting
self.value = text
layout.addWidget(self.label)
# Make sure the widget itself uses an expanding size policy
self.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.MinimumExpanding)
# Ensure we have a reasonable height to start with
# This helps ensure text is visible before resizing calculations
min_height = 40 if text else 20
self.setMinimumHeight(min_height)
def setText(self, text):
"""Set the text of the label."""
self.label.setText(text)
self.value = text
# Trigger layout update
self.updateGeometry()
def sizeHint(self):
"""Provide a size hint based on the text content."""
# Get the width of our container (usually the table cell)
width = self.width() or 300
# If text is empty, return minimal size
if not self.value:
return QSize(width, 20)
# Calculate height for wrapped text
font_metrics = self.label.fontMetrics()
# Estimate how much space the text will need when wrapped
text_rect = font_metrics.boundingRect(
0,
0,
width - 10,
1000, # Width constraint, virtually unlimited height
Qt.TextWordWrap,
self.value,
)
# Add some padding
height = text_rect.height() + 8
return QSize(width, max(30, height))
def resizeEvent(self, event):
"""Handle resize events to ensure text is properly displayed."""
super().resizeEvent(event)
# When resized (especially width change), update layout to ensure text wrapping works
self.label.updateGeometry()
self.updateGeometry()
class SortableTableWidgetItem(QTableWidgetItem):
"""Table widget item that enables proper sorting for different data types."""
def __lt__(self, other):
"""Compare items for sorting."""
if self.text() == "Yes" and other.text() == "No":
return True
elif self.text() == "No" and other.text() == "Yes":
return False
else:
return self.text().lower() < other.text().lower()
class DeviceTagsWidget(BECWidget, QWidget):
"""Widget to display devices grouped by their tags in containers."""
def __init__(self, parent=None):
super().__init__(parent=parent)
self.layout = QVBoxLayout(self)
self.setLayout(self.layout)
# Title
self.title_label = QLabel("Device Tags")
self.title_label.setStyleSheet("font-weight: bold; font-size: 14px;")
self.layout.addWidget(self.title_label)
# Search bar for tags
self.search_layout = QHBoxLayout()
self.search_label = QLabel("Search:")
self.search_input = QLineEdit()
self.search_input.setPlaceholderText("Filter tags...")
self.search_input.setClearButtonEnabled(True)
self.search_input.textChanged.connect(self.filter_tags)
self.search_layout.addWidget(self.search_label)
self.search_layout.addWidget(self.search_input)
self.layout.addLayout(self.search_layout)
# Create a scroll area for tag containers
self.scroll_area = QScrollArea()
self.scroll_area.setWidgetResizable(True)
self.scroll_area.setHorizontalScrollBarPolicy(Qt.ScrollBarAsNeeded)
self.scroll_area.setVerticalScrollBarPolicy(Qt.ScrollBarAsNeeded)
# Create a widget to hold all tag containers
self.scroll_widget = QWidget()
self.scroll_layout = QVBoxLayout(self.scroll_widget)
self.scroll_layout.setSpacing(10)
self.scroll_layout.setContentsMargins(5, 5, 5, 5)
self.scroll_layout.setAlignment(Qt.AlignTop)
self.scroll_area.setWidget(self.scroll_widget)
self.layout.addWidget(self.scroll_area)
# Initialize with empty data
self.all_devices = []
self.active_devices = []
self.device_tags = {} # Maps tag names to lists of device names
self.tag_containers = {} # Maps tag names to their container widgets
# Load initial data
self.update_tags()
def update_tags(self):
"""Update the tags containers with current device information."""
try:
# Get device config
config = self.client.device_manager._get_redis_device_config()
# Clear current data
self.all_devices = []
self.active_devices = []
self.device_tags = {}
# Process device config
for device_info in config:
device_name = device_info.get("name", "Unknown")
self.all_devices.append(device_name)
# Add to active devices if enabled
if device_info.get("enabled", False):
self.active_devices.append(device_name)
# Process device tags
tags = device_info.get("deviceTags", [])
for tag in tags:
if tag not in self.device_tags:
self.device_tags[tag] = []
self.device_tags[tag].append(device_name)
# Update the tag containers
self.populate_tag_containers()
except Exception as e:
ErrorPopupUtility().show_error_message(
"Device Tags Error", f"Error updating device tags: {str(e)}", self
)
def populate_tag_containers(self):
"""Populate the containers with current tag and device data."""
# Save current filter before clearing
current_filter = self.search_input.text() if hasattr(self, "search_input") else ""
# Clear existing containers
for i in reversed(range(self.scroll_layout.count())):
widget = self.scroll_layout.itemAt(i).widget()
if widget:
widget.setParent(None)
widget.deleteLater()
self.tag_containers = {}
# Add tag containers
for tag, devices in sorted(self.device_tags.items()):
# Create container frame for this tag
container = QFrame()
container.setFrameStyle(QFrame.StyledPanel | QFrame.Raised)
container.setStyleSheet(
"QFrame { background-color: palette(window); border: 1px solid palette(mid); border-radius: 4px; }"
)
container_layout = QVBoxLayout(container)
container_layout.setContentsMargins(10, 10, 10, 10)
# Add tag header with status indicator
header_layout = QHBoxLayout()
# Tag name label
tag_label = QLabel(tag)
tag_label.setStyleSheet("font-weight: bold;")
header_layout.addWidget(tag_label)
# Spacer to push status to the right
header_layout.addStretch()
# Status indicator
all_devices_count = len(devices)
active_devices_count = sum(1 for d in devices if d in self.active_devices)
if active_devices_count == 0:
status_text = "None"
status_color = "red"
elif active_devices_count == all_devices_count:
status_text = "All"
status_color = "green"
else:
status_text = f"{active_devices_count}/{all_devices_count}"
status_color = "orange"
status_label = QLabel(status_text)
status_label.setStyleSheet(f"color: {status_color}; font-weight: bold;")
header_layout.addWidget(status_label)
container_layout.addLayout(header_layout)
# Add divider line
line = QFrame()
line.setFrameShape(QFrame.HLine)
line.setFrameShadow(QFrame.Sunken)
container_layout.addWidget(line)
# Add device list
device_list = QListWidget()
device_list.setAlternatingRowColors(True)
device_list.setMaximumHeight(150) # Limit height
# Add devices to the list
for device_name in sorted(devices):
item = QListWidgetItem(device_name)
if device_name in self.active_devices:
item.setForeground(Qt.green)
else:
item.setForeground(Qt.red)
device_list.addItem(item)
container_layout.addWidget(device_list)
# Add to the scroll layout
self.scroll_layout.addWidget(container)
self.tag_containers[tag] = container
# Add a stretch at the end to push all containers to the top
self.scroll_layout.addStretch()
# Reapply filter if there was one
if current_filter:
self.filter_tags(current_filter)
@SafeSlot(str)
def filter_tags(self, text):
"""Filter the tag containers based on search text."""
if not hasattr(self, "tag_containers"):
return
text = text.lower()
# Show/hide tag containers based on filter
for tag, container in self.tag_containers.items():
if not text or text in tag.lower():
# Tag matches filter
container.show()
else:
# Check if any device in this tag matches
matches = False
for device in self.device_tags.get(tag, []):
if text in device.lower():
matches = True
break
container.setVisible(matches)
@SafeSlot()
def add_devices_by_tag(self):
"""Add devices with the selected tags to the active configuration."""
# This would be implemented for drag-and-drop in the future
@SafeSlot()
def remove_devices_by_tag(self):
"""Remove devices with the selected tags from the active configuration."""
# This would be implemented for drag-and-drop in the future
class DeviceManager(BECWidget, QWidget):
"""Widget to display the current device configuration in a table."""
def __init__(self, parent=None):
super().__init__(parent=parent)
# Main layout for the entire widget
self.main_layout = QHBoxLayout(self)
self.setLayout(self.main_layout)
# Create a splitter to hold the device tags widget and device table
self.splitter = QSplitter(Qt.Horizontal)
# Create device tags widget
self.device_tags_widget = DeviceTagsWidget(self)
self.splitter.addWidget(self.device_tags_widget)
# Create container for device table and its controls
self.table_container = QWidget()
self.layout = QVBoxLayout(self.table_container)
# Create search bar
self.search_layout = QHBoxLayout()
self.search_label = QLabel("Search:")
self.search_input = QLineEdit()
self.search_input.setPlaceholderText(
"Filter devices (approximate matching)..."
) # Default to fuzzy search
self.search_input.setClearButtonEnabled(True)
self.search_input.textChanged.connect(self.filter_devices)
self.search_layout.addWidget(self.search_label)
self.search_layout.addWidget(self.search_input)
# Add exact match toggle
self.fuzzy_toggle_layout = QHBoxLayout()
self.fuzzy_toggle_label = QLabel("Exact Match:")
self.fuzzy_toggle = ToggleSwitch()
self.fuzzy_toggle.setChecked(False) # Default to fuzzy search (toggle OFF)
self.fuzzy_toggle.stateChanged.connect(self.on_fuzzy_toggle_changed)
self.fuzzy_toggle.setToolTip(
"Toggle between approximate matching (OFF) and exact matching (ON)"
)
self.fuzzy_toggle_label.setToolTip(
"Toggle between approximate matching (OFF) and exact matching (ON)"
)
self.fuzzy_toggle_layout.addWidget(self.fuzzy_toggle_label)
self.fuzzy_toggle_layout.addWidget(self.fuzzy_toggle)
self.fuzzy_toggle_layout.addStretch()
# Add both search components to the layout
self.search_controls = QHBoxLayout()
self.search_controls.addLayout(self.search_layout)
self.search_controls.addSpacing(20) # Add some space between the search box and toggle
self.search_controls.addLayout(self.fuzzy_toggle_layout)
self.layout.addLayout(self.search_controls)
# Create table widget
self.device_table = BECTable()
self.device_table.setEditTriggers(QTableWidget.NoEditTriggers) # Make table read-only
self.device_table.setSelectionBehavior(QTableWidget.SelectRows)
self.device_table.setAlternatingRowColors(True)
self.layout.addWidget(self.device_table)
# Connect custom sorting handler
self.device_table.horizontalHeader().sectionClicked.connect(self.handle_header_click)
self.current_sort_section = 0
self.current_sort_order = Qt.AscendingOrder
# Make table resizable
self.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding)
self.device_table.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding)
# Don't stretch the last section to prevent it from changing width
self.device_table.horizontalHeader().setStretchLastSection(False)
self.device_table.verticalHeader().setVisible(False)
# Set up initial headers
self.headers = [
"Name",
"Device Class",
"Readout Priority",
"Enabled",
"Read Only",
"Documentation",
]
self.device_table.setColumnCount(len(self.headers))
self.device_table.setHorizontalHeaderLabels(self.headers)
# Set initial column resize modes
header = self.device_table.horizontalHeader()
header.setSectionResizeMode(0, QHeaderView.ResizeToContents) # Name
header.setSectionResizeMode(1, QHeaderView.ResizeToContents) # Device Class
header.setSectionResizeMode(2, QHeaderView.ResizeToContents) # Readout Priority
header.setSectionResizeMode(3, QHeaderView.Fixed) # Enabled
header.setSectionResizeMode(4, QHeaderView.Fixed) # Read Only
header.setSectionResizeMode(5, QHeaderView.Stretch) # Documentation
# Connect resize signal to adjust row heights when table is resized
self.device_table.horizontalHeader().sectionResized.connect(self.on_table_resized)
# Set fixed width for checkbox columns
self.device_table.setColumnWidth(3, 70) # Enabled column
self.device_table.setColumnWidth(4, 70) # Read Only column
# Ensure column widths stay fixed
header.setMinimumSectionSize(70)
header.setDefaultSectionSize(100)
# Enable sorting by clicking column headers
self.device_table.setSortingEnabled(False) # We'll handle sorting manually
self.device_table.horizontalHeader().setSortIndicatorShown(True)
self.device_table.horizontalHeader().setSectionsClickable(True)
# Add buttons for adding/removing devices
self.button_layout = QHBoxLayout()
# Add device button
self.add_device_button = QPushButton("Add Device")
self.add_device_button.clicked.connect(self.add_device)
self.button_layout.addWidget(self.add_device_button)
# Remove device button
self.remove_device_button = QPushButton("Remove Device")
self.remove_device_button.clicked.connect(self.remove_device)
self.button_layout.addWidget(self.remove_device_button)
# Add buttons to main layout
self.layout.addLayout(self.button_layout)
# Add the table container to the splitter
self.splitter.addWidget(self.table_container)
# Set initial sizes (30% for tags, 70% for table)
self.splitter.setSizes([300, 700])
# Add the splitter to the main layout
self.main_layout.addWidget(self.splitter)
# Connect signals between widgets
self.connect_signals()
# Load initial data
self.update_device_table()
def connect_signals(self):
"""Connect signals between the device table and tags widget."""
# Connect add devices by tag button to update the table
if hasattr(self.device_tags_widget, "add_tag_button"):
self.device_tags_widget.add_tag_button.clicked.connect(self.update_device_table)
# Connect remove devices by tag button to update the table
if hasattr(self.device_tags_widget, "remove_tag_button"):
self.device_tags_widget.remove_tag_button.clicked.connect(self.update_device_table)
@SafeSlot(int, int, int)
def on_table_resized(self, column, old_width, new_width):
"""Handle table column resize events to readjust row heights for text wrapping."""
# Only handle resizes of the documentation column
if column == 5:
# Update all rows with TextLabelWidgets in the documentation column
for row in range(self.device_table.rowCount()):
doc_widget = self.device_table.cellWidget(row, 5)
if doc_widget and isinstance(doc_widget, TextLabelWidget):
# Trigger recalculation of text wrapping
doc_widget.updateGeometry()
# Force the table to recalculate row heights
if doc_widget.value:
# Get text metrics
font_metrics = doc_widget.label.fontMetrics()
# Calculate new text height with word wrap
text_rect = font_metrics.boundingRect(
0,
0,
new_width - 10,
2000, # New width constraint
Qt.TextWordWrap,
doc_widget.value,
)
# Update row height
row_height = text_rect.height() + 16
self.device_table.setRowHeight(row, max(40, row_height))
@SafeSlot()
def update_device_table(self):
"""Update the device table with the current device configuration."""
try:
# Get device config (always a list of dictionaries)
config = self.client.device_manager._get_redis_device_config()
# Clear existing rows
self.device_table.setRowCount(0)
# Add devices to the table
for device_info in config:
row_position = self.device_table.rowCount()
self.device_table.insertRow(row_position)
# Set device name
self.device_table.setItem(
row_position, 0, SortableTableWidgetItem(device_info.get("name", "Unknown"))
)
# Set device class
device_class = device_info.get("deviceClass", "Unknown")
self.device_table.setItem(row_position, 1, SortableTableWidgetItem(device_class))
# Set readout priority
readout_priority = device_info.get("readoutPriority", "Unknown")
self.device_table.setItem(
row_position, 2, SortableTableWidgetItem(readout_priority)
)
# Set enabled status as checkbox
enabled_checkbox = CheckBoxCenterWidget(device_info.get("enabled", False))
self.device_table.setCellWidget(row_position, 3, enabled_checkbox)
# Set read-only status as checkbox
readonly_checkbox = CheckBoxCenterWidget(device_info.get("readOnly", False))
self.device_table.setCellWidget(row_position, 4, readonly_checkbox)
# Set documentation using text label widget with word wrap
documentation = device_info.get("documentation", "")
doc_widget = TextLabelWidget(documentation)
self.device_table.setCellWidget(row_position, 5, doc_widget)
# First, ensure the table is updated to show the new widgets
self.device_table.viewport().update()
# Force a layout update to get proper sizes
self.device_table.resizeRowsToContents()
# Then adjust row heights with better calculation for wrapped text
for row in range(self.device_table.rowCount()):
doc_widget = self.device_table.cellWidget(row, 5)
if doc_widget and isinstance(doc_widget, TextLabelWidget):
text = doc_widget.value
if text:
# Get the column width
col_width = self.device_table.columnWidth(5)
# Calculate appropriate height for the text
font_metrics = doc_widget.label.fontMetrics()
# Calculate text rectangle with word wrap
text_rect = font_metrics.boundingRect(
0,
0,
col_width - 10,
2000, # Width constraint with large height
Qt.TextWordWrap,
text,
)
# Set row height with additional padding
row_height = text_rect.height() + 16
self.device_table.setRowHeight(row, max(40, row_height))
# Update the widget to reflect the new size
doc_widget.updateGeometry()
# Apply current sort if any
if hasattr(self, "current_sort_section") and self.current_sort_section >= 0:
self.sort_table(self.current_sort_section, self.current_sort_order)
self.device_table.horizontalHeader().setSortIndicator(
self.current_sort_section, self.current_sort_order
)
# Reset the filter to make sure search works with new data
if hasattr(self, "search_input"):
current_filter = self.search_input.text()
if current_filter:
self.filter_devices(current_filter)
# Update the device tags widget
self.device_tags_widget.update_tags()
except Exception as e:
ErrorPopupUtility().show_error_message(
"Device Manager Error", f"Error updating device table: {str(e)}", self
)
@SafeSlot(bool)
def on_fuzzy_toggle_changed(self, enabled):
"""
Handle exact match toggle state change.
When toggle is ON (enabled=True): Use exact matching
When toggle is OFF (enabled=False): Use fuzzy/approximate matching
"""
# Update search mode label
if hasattr(self, "search_input"):
# Store original stylesheet to restore it later
original_style = self.search_input.styleSheet()
# Set placeholder text based on mode
if enabled: # Toggle ON = Exact match
self.search_input.setPlaceholderText("Filter devices (exact match)...")
print("Toggle switched ON: Using EXACT match mode")
else: # Toggle OFF = Approximate/fuzzy match
self.search_input.setPlaceholderText("Filter devices (approximate matching)...")
print("Toggle switched OFF: Using FUZZY match mode")
# Visual feedback - briefly highlight the search box with appropriate color
highlight_color = "#3498db" # Blue for feedback
self.search_input.setStyleSheet(f"border: 2px solid {highlight_color};")
# Create a one-time timer to restore the original style after a short delay
from qtpy.QtCore import QTimer
QTimer.singleShot(500, lambda: self.search_input.setStyleSheet(original_style))
# Log the toggle state for debugging
print(
f"Search mode changed: Exact match = {enabled}, Toggle isChecked = {self.fuzzy_toggle.isChecked()}"
)
# When toggle changes, reapply current search with new mode
current_text = self.search_input.text()
# Always reapply the filter, even if text is empty
# This ensures all rows are properly shown/hidden based on the new mode
self.filter_devices(current_text)
@SafeSlot(str)
def filter_devices(self, text):
"""Filter devices in the table based on exact or approximate matching."""
# Always show all rows when search is empty, regardless of match mode
if not text:
for row in range(self.device_table.rowCount()):
self.device_table.setRowHidden(row, False)
return
# Get current search mode
# When toggle is ON, we use exact match
# When toggle is OFF, we use fuzzy/approximate match
use_exact_match = hasattr(self, "fuzzy_toggle") and self.fuzzy_toggle.isChecked()
# Debug print to verify which mode is being used
print(f"Filtering with exact match: {use_exact_match}, search text: '{text}'")
# Threshold for fuzzy matching (0-100, higher is more strict)
threshold = 80
# Prepare search text (lowercase for case-insensitive search)
search_text = text.lower()
# Count of matched rows for feedback (but avoid double-counting)
visible_rows = 0
total_rows = self.device_table.rowCount()
# Filter rows using either exact or approximate matching
for row in range(total_rows):
row_visible = False
# Check name and device class columns (0 and 1)
for col in [0, 1]: # Name and Device Class columns
item = self.device_table.item(row, col)
if not item:
continue
cell_text = item.text().lower()
if use_exact_match:
# EXACT MATCH: Simple substring check
if search_text in cell_text:
row_visible = True
break
else:
# FUZZY MATCH: Use approximate matching
match_ratio = fuzz.partial_ratio(search_text, cell_text)
if match_ratio >= threshold:
row_visible = True
break
# Hide or show this row
self.device_table.setRowHidden(row, not row_visible)
# Count visible rows for potential feedback
if row_visible:
visible_rows += 1
@SafeSlot(int)
def handle_header_click(self, section):
"""Handle column header click to sort the table."""
# Toggle sort order if clicking the same section
if section == self.current_sort_section:
self.current_sort_order = (
Qt.DescendingOrder
if self.current_sort_order == Qt.AscendingOrder
else Qt.AscendingOrder
)
else:
self.current_sort_section = section
self.current_sort_order = Qt.AscendingOrder
# Update sort indicator
self.device_table.horizontalHeader().setSortIndicator(
self.current_sort_section, self.current_sort_order
)
# Perform the sort
self.sort_table(section, self.current_sort_order)
def sort_table(self, column, order):
"""Sort the table by the specified column and order."""
row_count = self.device_table.rowCount()
if row_count <= 1:
return # Nothing to sort
# Collect all rows for sorting
rows_data = []
for row in range(row_count):
# Create a safe copy of the row data
row_data = {}
row_data["items"] = []
row_data["widgets"] = []
row_data["hidden"] = self.device_table.isRowHidden(row)
row_data["sort_key"] = None
# Extract sort key for this row
if column in [3, 4]: # Checkbox columns
widget = self.device_table.cellWidget(row, column)
if widget and hasattr(widget, "value"):
row_data["sort_key"] = widget.value
else:
row_data["sort_key"] = False
else: # Text columns
item = self.device_table.item(row, column)
if item:
row_data["sort_key"] = item.text().lower()
else:
row_data["sort_key"] = ""
# Collect all items and widgets in the row
for col in range(self.device_table.columnCount()):
if col in [3, 4]: # Checkbox columns
widget = self.device_table.cellWidget(row, col)
if widget:
# Store the widget value to recreate it
is_checked = False
if hasattr(widget, "value"):
is_checked = widget.value
elif hasattr(widget, "checkbox"):
is_checked = widget.checkbox.isChecked()
row_data["widgets"].append((col, "checkbox", is_checked))
elif col == 5: # Documentation column with TextLabelWidget
widget = self.device_table.cellWidget(row, col)
if widget and isinstance(widget, TextLabelWidget):
text = widget.value
row_data["widgets"].append((col, "textlabel", text))
else:
row_data["widgets"].append((col, "textlabel", ""))
else:
item = self.device_table.item(row, col)
if item:
row_data["items"].append((col, item.text()))
else:
row_data["items"].append((col, ""))
rows_data.append(row_data)
# Sort the rows
reverse = order == Qt.DescendingOrder
sorted_rows = sorted(rows_data, key=lambda x: x["sort_key"], reverse=reverse)
# Rebuild the table with sorted data
self.device_table.setUpdatesEnabled(False) # Disable updates while rebuilding
# Clear and rebuild the table
self.device_table.clearContents()
self.device_table.setRowCount(row_count)
for row, row_data in enumerate(sorted_rows):
# Add text items
for col, text in row_data["items"]:
self.device_table.setItem(row, col, SortableTableWidgetItem(text))
# Add widgets
for col, widget_type, value in row_data["widgets"]:
if widget_type == "checkbox":
checkbox = CheckBoxCenterWidget(value)
self.device_table.setCellWidget(row, col, checkbox)
elif widget_type == "textlabel":
text_label = TextLabelWidget(value)
self.device_table.setCellWidget(row, col, text_label)
# Restore hidden state
self.device_table.setRowHidden(row, row_data["hidden"])
self.device_table.setUpdatesEnabled(True) # Re-enable updates
@SafeSlot()
def show_add_device_dialog(self):
"""Show the dialog for adding a new device."""
# Call the add_device method to handle the dialog and logic
self.add_device()
@SafeSlot()
def add_device(self):
"""Simulate adding a new device to the configuration."""
try:
# Create and show the add device dialog
dialog = AddDeviceDialog(self)
if dialog.exec():
# Get device config from dialog
device_config = dialog.get_device_config()
device_name = device_config.get("name")
# Print the action that would be taken (simulation only)
print(f"Would add device: {device_name} with config: {device_config}")
# Show simulation message
QMessageBox.information(
self,
"Device Addition Simulated",
f"Would add device: {device_name} (simulation only)",
)
# Update the device tags widget
self.device_tags_widget.update_tags()
except Exception as e:
ErrorPopupUtility().show_error_message(
"Device Manager Error", f"Error in add device simulation: {str(e)}", self
)
@SafeSlot()
def remove_device(self):
"""Simulate removing selected device(s) from the configuration."""
selected_rows = self.device_table.selectionModel().selectedRows()
if not selected_rows:
QMessageBox.information(self, "No Selection", "Please select a device to remove.")
return
# Confirm deletion
device_count = len(selected_rows)
message = f"Are you sure you want to remove {device_count} device{'s' if device_count > 1 else ''}?"
confirmation = QMessageBox.question(
self, "Confirm Removal", message, QMessageBox.Yes | QMessageBox.No
)
if confirmation == QMessageBox.Yes:
try:
# Get device names from selected rows
device_names = []
for index in selected_rows:
row = index.row()
device_name = self.device_table.item(row, 0).text()
device_names.append(device_name)
# Print removal action instead of actual removal
print(f"Would remove devices: {device_names}")
# Show simulation message
QMessageBox.information(
self,
"Device Removal Simulated",
f"Would remove {device_count} device{'s' if device_count > 1 else ''} (simulation only)",
)
# Update the device tags widget
self.device_tags_widget.update_tags()
except Exception as e:
ErrorPopupUtility().show_error_message(
"Device Manager Error", f"Error in remove device simulation: {str(e)}", self
)
class AddDeviceDialog(QDialog):
"""Dialog for adding a new device to the configuration."""
def __init__(self, parent=None):
super().__init__(parent)
self.setWindowTitle("Add New Device")
self.setMinimumWidth(400)
# Create layout
self.layout = QVBoxLayout(self)
self.form_layout = QFormLayout()
# Device name
self.name_input = QLineEdit()
self.form_layout.addRow("Device Name:", self.name_input)
# Device class
self.device_class_input = QLineEdit()
self.device_class_input.setText("ophyd_devices.SimPositioner")
self.form_layout.addRow("Device Class:", self.device_class_input)
# Readout priority
self.readout_priority_combo = QComboBox()
self.readout_priority_combo.addItems(["baseline", "monitored", "async", "on_request"])
self.form_layout.addRow("Readout Priority:", self.readout_priority_combo)
# Enabled checkbox
self.enabled_checkbox = QCheckBox()
self.enabled_checkbox.setChecked(True)
self.form_layout.addRow("Enabled:", self.enabled_checkbox)
# Read-only checkbox
self.readonly_checkbox = QCheckBox()
self.form_layout.addRow("Read Only:", self.readonly_checkbox)
# Documentation text
self.documentation_input = QLineEdit()
self.form_layout.addRow("Documentation:", self.documentation_input)
# Add form to layout
self.layout.addLayout(self.form_layout)
# Add buttons
self.button_layout = QHBoxLayout()
self.cancel_button = QPushButton("Cancel")
self.cancel_button.clicked.connect(self.reject)
self.add_button = QPushButton("Add Device")
self.add_button.clicked.connect(self.accept)
self.button_layout.addWidget(self.cancel_button)
self.button_layout.addWidget(self.add_button)
self.layout.addLayout(self.button_layout)
def get_device_config(self):
"""Get the device configuration from the dialog."""
return {
"name": self.name_input.text(),
"deviceClass": self.device_class_input.text(),
"readoutPriority": self.readout_priority_combo.currentText(),
"enabled": self.enabled_checkbox.isChecked(),
"readOnly": self.readonly_checkbox.isChecked(),
"documentation": self.documentation_input.text(),
"deviceConfig": {}, # Empty config for now
}
if __name__ == "__main__":
from qtpy.QtWidgets import QApplication
app = QApplication([])
window = DeviceManager()
window.show()
app.exec_()

View File

@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project]
name = "bec_widgets"
version = "2.34.0"
version = "2.35.0"
description = "BEC Widgets"
requires-python = ">=3.10"
classifiers = [
@@ -20,7 +20,7 @@ dependencies = [
"isort~=5.13, >=5.13.2", # needed for bw-generate-cli
"pydantic~=2.0",
"pyqtgraph~=0.13",
"PySide6~=6.8.2",
"PySide6==6.9.0",
"qtconsole~=5.5, >=5.5.1", # needed for jupyter console
"qtpy~=2.4",
"qtmonaco~=0.5",

View File

@@ -0,0 +1,586 @@
from unittest.mock import Mock
import pytest
from qtpy import QtWidgets
from qtpy.QtCore import QLocale, QPoint, QPointF, QRect, QRectF, QSize, QSizeF, Qt
from qtpy.QtGui import QColor, QCursor, QFont, QIcon, QPalette
from qtpy.QtWidgets import QLabel, QPushButton, QSizePolicy, QWidget
from bec_widgets.utils.property_editor import PropertyEditor
class TestWidget(QWidget):
"""Test widget with various property types for testing the property editor."""
def __init__(self, parent=None):
super().__init__(parent)
self.setObjectName("TestWidget")
# Set up various properties that will appear in the property editor
self.setMinimumSize(100, 50)
self.setMaximumSize(500, 300)
self.setStyleSheet("background-color: red;")
self.setToolTip("Test tooltip")
self.setEnabled(True)
self.setVisible(True)
class BECTestWidget(QWidget):
"""Test widget that simulates a BEC widget."""
def __init__(self, parent=None):
super().__init__(parent)
self.setObjectName("BECTestWidget")
# This widget's module will be set to simulate a bec_widgets module
self.__module__ = "bec_widgets.test.widget"
@pytest.fixture
def test_widget(qtbot):
"""Fixture providing a test widget with various properties."""
widget = TestWidget()
qtbot.addWidget(widget)
qtbot.waitExposed(widget)
return widget
@pytest.fixture
def bec_test_widget(qtbot):
"""Fixture providing a BEC test widget."""
widget = BECTestWidget()
qtbot.addWidget(widget)
qtbot.waitExposed(widget)
return widget
@pytest.fixture
def property_editor(qtbot, test_widget):
"""Fixture providing a property editor with a test widget."""
editor = PropertyEditor(test_widget, show_only_bec=False)
qtbot.addWidget(editor)
qtbot.waitExposed(editor)
return editor
@pytest.fixture
def bec_property_editor(qtbot, bec_test_widget):
"""Fixture providing a property editor with BEC-only mode."""
editor = PropertyEditor(bec_test_widget, show_only_bec=True)
qtbot.addWidget(editor)
qtbot.waitExposed(editor)
return editor
# ------------------------------------------------------------------------
# Basic functionality tests
# ------------------------------------------------------------------------
def test_initialization(property_editor, test_widget):
"""Test that the property editor initializes correctly."""
assert property_editor._target == test_widget
assert property_editor._bec_only is False
assert property_editor.tree.columnCount() == 2
assert property_editor.tree.headerItem().text(0) == "Property"
assert property_editor.tree.headerItem().text(1) == "Value"
def test_bec_only_mode(bec_property_editor):
"""Test BEC-only mode filtering."""
assert bec_property_editor._bec_only is True
# Should have items since bec_test_widget simulates a BEC widget
assert bec_property_editor.tree.topLevelItemCount() >= 0
def test_class_chain(property_editor, test_widget):
"""Test that _class_chain returns correct metaobject hierarchy."""
chain = property_editor._class_chain()
assert len(chain) > 0
# First item should be the most derived class
assert chain[0].className() in ["TestWidget", "QWidget"]
def test_set_show_only_bec_toggle(property_editor):
"""Test toggling BEC-only mode rebuilds the tree."""
initial_count = property_editor.tree.topLevelItemCount()
# Toggle to BEC-only mode
property_editor.set_show_only_bec(True)
assert property_editor._bec_only is True
# Toggle back
property_editor.set_show_only_bec(False)
assert property_editor._bec_only is False
# ------------------------------------------------------------------------
# Editor creation tests
# ------------------------------------------------------------------------
def test_make_sizepolicy_editor(property_editor):
"""Test size policy editor creation and functionality."""
size_policy = QSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed)
size_policy.setHorizontalStretch(1)
size_policy.setVerticalStretch(2)
editor = property_editor._make_sizepolicy_editor("sizePolicy", size_policy)
assert editor is not None
# Should return None for non-QSizePolicy input
editor_none = property_editor._make_sizepolicy_editor("test", "not_a_sizepolicy")
assert editor_none is None
def test_make_locale_editor(property_editor):
"""Test locale editor creation."""
locale = QLocale(QLocale.English, QLocale.UnitedStates)
editor = property_editor._make_locale_editor("locale", locale)
assert editor is not None
# Should return None for non-QLocale input
editor_none = property_editor._make_locale_editor("test", "not_a_locale")
assert editor_none is None
def test_make_icon_editor(property_editor):
"""Test icon editor creation."""
icon = QIcon()
editor = property_editor._make_icon_editor("icon", icon)
assert editor is not None
assert isinstance(editor, QPushButton)
assert "Choose" in editor.text()
def test_make_font_editor(property_editor):
"""Test font editor creation."""
font = QFont("Arial", 12)
editor = property_editor._make_font_editor("font", font)
assert editor is not None
assert isinstance(editor, QPushButton)
assert "Arial" in editor.text()
assert "12" in editor.text()
# Test with non-font value
editor_no_font = property_editor._make_font_editor("font", None)
assert "Select font" in editor_no_font.text()
def test_make_color_editor(property_editor):
"""Test color editor creation."""
color = QColor(255, 0, 0) # Red color
apply_called = []
def apply_callback(col):
apply_called.append(col)
editor = property_editor._make_color_editor(color, apply_callback)
assert editor is not None
assert isinstance(editor, QPushButton)
assert color.name() in editor.text()
def test_make_cursor_editor(property_editor):
"""Test cursor editor creation."""
cursor = QCursor(Qt.CrossCursor)
editor = property_editor._make_cursor_editor("cursor", cursor)
assert editor is not None
assert isinstance(editor, QtWidgets.QComboBox)
def test_spin_pair_int(property_editor):
"""Test _spin_pair with integer spinboxes."""
wrap, box1, box2 = property_editor._spin_pair(ints=True)
assert wrap is not None
assert isinstance(box1, QtWidgets.QSpinBox)
assert isinstance(box2, QtWidgets.QSpinBox)
assert box1.minimum() == -10_000_000
assert box1.maximum() == 10_000_000
def test_spin_pair_float(property_editor):
"""Test _spin_pair with double spinboxes."""
wrap, box1, box2 = property_editor._spin_pair(ints=False)
assert wrap is not None
assert isinstance(box1, QtWidgets.QDoubleSpinBox)
assert isinstance(box2, QtWidgets.QDoubleSpinBox)
assert box1.decimals() == 6
def test_spin_quad_int(property_editor):
"""Test _spin_quad with integer spinboxes."""
wrap, boxes = property_editor._spin_quad(ints=True)
assert wrap is not None
assert len(boxes) == 4
assert all(isinstance(box, QtWidgets.QSpinBox) for box in boxes)
def test_spin_quad_float(property_editor):
"""Test _spin_quad with double spinboxes."""
wrap, boxes = property_editor._spin_quad(ints=False)
assert wrap is not None
assert len(boxes) == 4
assert all(isinstance(box, QtWidgets.QDoubleSpinBox) for box in boxes)
# ------------------------------------------------------------------------
# Property type editor tests
# ------------------------------------------------------------------------
def test_make_editor_qsize(property_editor):
"""Test editor creation for QSize properties."""
size = QSize(100, 200)
mock_prop = Mock()
mock_prop.isEnumType.return_value = False
editor = property_editor._make_editor("size", size, mock_prop)
assert editor is not None
def test_make_editor_qsizef(property_editor):
"""Test editor creation for QSizeF properties."""
sizef = QSizeF(100.5, 200.7)
mock_prop = Mock()
mock_prop.isEnumType.return_value = False
editor = property_editor._make_editor("sizef", sizef, mock_prop)
assert editor is not None
def test_make_editor_qpoint(property_editor):
"""Test editor creation for QPoint properties."""
point = QPoint(10, 20)
mock_prop = Mock()
mock_prop.isEnumType.return_value = False
editor = property_editor._make_editor("point", point, mock_prop)
assert editor is not None
def test_make_editor_qpointf(property_editor):
"""Test editor creation for QPointF properties."""
pointf = QPointF(10.5, 20.7)
mock_prop = Mock()
mock_prop.isEnumType.return_value = False
editor = property_editor._make_editor("pointf", pointf, mock_prop)
assert editor is not None
def test_make_editor_qrect(property_editor):
"""Test editor creation for QRect properties."""
rect = QRect(10, 20, 100, 200)
mock_prop = Mock()
mock_prop.isEnumType.return_value = False
editor = property_editor._make_editor("rect", rect, mock_prop)
assert editor is not None
def test_make_editor_qrectf(property_editor):
"""Test editor creation for QRectF properties."""
rectf = QRectF(10.5, 20.7, 100.5, 200.7)
mock_prop = Mock()
mock_prop.isEnumType.return_value = False
editor = property_editor._make_editor("rectf", rectf, mock_prop)
assert editor is not None
def test_make_editor_bool(property_editor):
"""Test editor creation for boolean properties."""
mock_prop = Mock()
mock_prop.isEnumType.return_value = False
editor = property_editor._make_editor("enabled", True, mock_prop)
assert editor is not None
assert isinstance(editor, QtWidgets.QCheckBox)
assert editor.isChecked() is True
def test_make_editor_int(property_editor):
"""Test editor creation for integer properties."""
mock_prop = Mock()
mock_prop.isEnumType.return_value = False
editor = property_editor._make_editor("value", 42, mock_prop)
assert editor is not None
assert isinstance(editor, QtWidgets.QSpinBox)
assert editor.value() == 42
def test_make_editor_float(property_editor):
"""Test editor creation for float properties."""
mock_prop = Mock()
mock_prop.isEnumType.return_value = False
editor = property_editor._make_editor("value", 3.14, mock_prop)
assert editor is not None
assert isinstance(editor, QtWidgets.QDoubleSpinBox)
assert editor.value() == 3.14
def test_make_editor_string(property_editor):
"""Test editor creation for string properties."""
mock_prop = Mock()
mock_prop.isEnumType.return_value = False
editor = property_editor._make_editor("text", "Hello World", mock_prop)
assert editor is not None
assert isinstance(editor, QtWidgets.QLineEdit)
assert editor.text() == "Hello World"
def test_make_editor_qcolor(property_editor):
"""Test editor creation for QColor properties."""
color = QColor(255, 0, 0)
mock_prop = Mock()
mock_prop.isEnumType.return_value = False
editor = property_editor._make_editor("color", color, mock_prop)
assert editor is not None
assert isinstance(editor, QPushButton)
def test_make_editor_qfont(property_editor):
"""Test editor creation for QFont properties."""
font = QFont("Arial", 12)
mock_prop = Mock()
mock_prop.isEnumType.return_value = False
editor = property_editor._make_editor("font", font, mock_prop)
assert editor is not None
assert isinstance(editor, QPushButton)
def test_make_editor_unsupported_type(property_editor):
"""Test editor creation for unsupported property types."""
mock_prop = Mock()
mock_prop.isEnumType.return_value = False
# Should return None for unsupported types
editor = property_editor._make_editor("unsupported", object(), mock_prop)
assert editor is None
# ------------------------------------------------------------------------
# Enum editor tests
# ------------------------------------------------------------------------
def test_make_enum_editor_non_flag(property_editor):
"""Test enum editor creation for non-flag enums."""
mock_prop = Mock()
mock_prop.isEnumType.return_value = True
mock_enum = Mock()
mock_enum.isFlag.return_value = False
mock_enum.keyCount.return_value = 3
mock_enum.key.side_effect = [b"Value1", b"Value2", b"Value3"]
mock_enum.value.side_effect = [0, 1, 2]
mock_prop.enumerator.return_value = mock_enum
editor = property_editor._make_enum_editor("enum_prop", 1, mock_prop)
assert editor is not None
assert isinstance(editor, QtWidgets.QComboBox)
# ------------------------------------------------------------------------
# Palette editor tests
# ------------------------------------------------------------------------
def test_make_palette_editor(property_editor):
"""Test palette editor creation."""
palette = QPalette()
palette.setColor(QPalette.Window, QColor(255, 255, 255))
editor = property_editor._make_palette_editor("palette", palette)
assert editor is not None
# Should return None for non-QPalette input
editor_none = property_editor._make_palette_editor("test", "not_a_palette")
assert editor_none is None
def test_apply_palette_color(property_editor, test_widget):
"""Test _apply_palette_color method."""
palette = test_widget.palette()
original_color = palette.color(QPalette.Active, QPalette.Window)
new_color = QColor(255, 0, 0)
property_editor._apply_palette_color(
"palette", palette, QPalette.Active, QPalette.Window, new_color
)
# Verify the property was set (this would normally update the widget)
assert palette.color(QPalette.Active, QPalette.Window) == new_color
# ------------------------------------------------------------------------
# Enum text processing tests
# ------------------------------------------------------------------------
def test_enum_text_non_flag(property_editor):
"""Test _enum_text for non-flag enums."""
mock_enum = Mock()
mock_enum.isFlag.return_value = False
mock_enum.valueToKey.return_value = b"TestValue"
result = property_editor._enum_text(mock_enum, 1)
assert result == "TestValue"
def test_enum_text_flag(property_editor):
"""Test _enum_text for flag enums."""
mock_enum = Mock()
mock_enum.isFlag.return_value = True
mock_enum.keyCount.return_value = 2
mock_enum.key.side_effect = [b"Flag1", b"Flag2"]
mock_enum.value.side_effect = [1, 2]
result = property_editor._enum_text(mock_enum, 3) # 1 | 2 = 3
assert "Flag1" in result and "Flag2" in result
def test_enum_value_to_int(property_editor):
"""Test _enum_value_to_int conversion."""
# Test with integer
assert property_editor._enum_value_to_int(Mock(), 42) == 42
# Test with object having value attribute
mock_obj = Mock()
mock_obj.value = 24
assert property_editor._enum_value_to_int(Mock(), mock_obj) == 24
# Test with mock enum for key lookup
mock_enum = Mock()
mock_enum.keyToValue.return_value = 10
mock_obj_with_name = Mock()
mock_obj_with_name.name = "TestKey"
assert property_editor._enum_value_to_int(mock_enum, mock_obj_with_name) == 10
# ------------------------------------------------------------------------
# Tree building and interaction tests
# ------------------------------------------------------------------------
def test_add_property_row(property_editor):
"""Test _add_property_row method."""
parent_item = QtWidgets.QTreeWidgetItem(["TestGroup"])
mock_prop = Mock()
mock_prop.isEnumType.return_value = False
property_editor._add_property_row(parent_item, "testProp", "testValue", mock_prop)
assert parent_item.childCount() == 1
child = parent_item.child(0)
assert child.text(0) == "testProp"
def test_set_equal_columns(property_editor):
"""Test _set_equal_columns method."""
# Set a specific width to test column sizing
property_editor.resize(400, 300)
property_editor._set_equal_columns()
# Verify columns are set up correctly
header = property_editor.tree.header()
assert header.sectionResizeMode(0) == QtWidgets.QHeaderView.Interactive
assert header.sectionResizeMode(1) == QtWidgets.QHeaderView.Interactive
def test_build_rebuilds_tree(property_editor):
"""Test that _build method clears and rebuilds the tree."""
initial_count = property_editor.tree.topLevelItemCount()
# Add a dummy item to ensure clearing works
dummy_item = QtWidgets.QTreeWidgetItem(["Dummy"])
property_editor.tree.addTopLevelItem(dummy_item)
# Rebuild
property_editor._build()
# The dummy item should be gone, tree should be rebuilt
assert property_editor.tree.topLevelItemCount() >= 0
# ------------------------------------------------------------------------
# Integration tests with Qt objects
# ------------------------------------------------------------------------
def test_property_change_integration(qtbot, property_editor, test_widget):
"""Test that property changes through editors update the target widget."""
# This test would require more complex setup to actually trigger editor changes
# For now, just verify the basic structure is there
assert property_editor._target == test_widget
# Verify that the tree has been populated with some properties
assert property_editor.tree.topLevelItemCount() >= 0
def test_widget_with_custom_properties(qtbot):
"""Test property editor with a widget that has custom properties."""
widget = QLabel("Test Label")
widget.setAlignment(Qt.AlignCenter)
widget.setWordWrap(True)
qtbot.addWidget(widget)
editor = PropertyEditor(widget, show_only_bec=False)
qtbot.addWidget(editor)
qtbot.waitExposed(editor)
# Should have populated the tree with QLabel properties
assert editor.tree.topLevelItemCount() > 0
# ------------------------------------------------------------------------
# Error handling tests
# ------------------------------------------------------------------------
def test_robust_enum_handling(property_editor):
"""Test that enum handling is robust against various edge cases."""
# Test with invalid enum values
mock_enum = Mock()
mock_enum.isFlag.return_value = False
mock_enum.valueToKey.return_value = None
result = property_editor._enum_text(mock_enum, 999)
assert result == "999" # Should fall back to string representation
# ------------------------------------------------------------------------
# Performance and memory tests
# ------------------------------------------------------------------------
def test_large_property_tree_performance(qtbot):
"""Test that the property editor handles widgets with many properties reasonably."""
# Create a widget with a deep inheritance hierarchy
widget = QtWidgets.QTextEdit()
widget.setPlainText("Test text with many properties")
qtbot.addWidget(widget)
editor = PropertyEditor(widget, show_only_bec=False)
qtbot.addWidget(editor)
# Should complete without hanging
qtbot.waitExposed(editor)
assert editor.tree.topLevelItemCount() > 0
def test_memory_cleanup_on_rebuild(property_editor):
"""Test that rebuilding the tree properly cleans up widgets."""
initial_count = property_editor.tree.topLevelItemCount()
# Trigger multiple rebuilds
for _ in range(3):
property_editor._build()
# Should not accumulate items
final_count = property_editor.tree.topLevelItemCount()
assert final_count >= 0 # Basic sanity check